UNPKG

@tiledesk/tiledesk-server

Version:
354 lines (297 loc) 11.6 kB
const crypto = require('crypto'); const COLUMN_TYPES = ['string', 'number', 'boolean', 'datetime']; const MATCH_MODES = ['all', 'any']; const FORBIDDEN_NAMES = ['__proto__', 'constructor', 'prototype', '$where']; const OPERATORS = { equal: '$eq', not_equal: '$ne', greater_than: '$gt', greater_or_equal: '$gte', less_than: '$lt', less_or_equal: '$lte', }; const STRING_OPERATORS = ['contains', 'starts_with', 'ends_with']; const NO_VALUE_OPERATORS = ['exists', 'not_exists']; function generateColumnId() { return 'col_' + crypto.randomBytes(8).toString('hex'); } const COLUMN_NAME_REGEX = /^[a-zA-Z0-9_]+$/; function isValidColumnName(name) { if (typeof name !== 'string' || name.length === 0) return false; if (name !== name.trim()) return false; if (!COLUMN_NAME_REGEX.test(name)) return false; if (FORBIDDEN_NAMES.indexOf(name) !== -1) return false; return true; } function assertValidColumnName(name, context) { if (!isValidColumnName(name)) { throw new Error( (context ? context + ': ' : '') + 'Column name must contain only letters, numbers and underscore, with no spaces' ); } } function normalizeSchemaForCreate(input) { if (input === undefined || input === null) return []; if (!Array.isArray(input)) throw new Error('schema must be an array'); let schema = []; let names = {}; for (let i = 0; i < input.length; i++) { const col = input[i]; if (!col || typeof col !== 'object') throw new Error('Invalid column at index ' + i); if (col.id) throw new Error('Column id must not be provided at index ' + i + '; ids are generated by the server'); const name = typeof col.name === 'string' ? col.name.trim() : ''; assertValidColumnName(name, 'Invalid column at index ' + i); if (!col.type || COLUMN_TYPES.indexOf(col.type) === -1) { throw new Error('Column type is required at index ' + i); } if (names[name]) throw new Error('Column name already exists: ' + name); names[name] = true; schema.push({ id: generateColumnId(), name: name, type: col.type, index: typeof col.index === 'number' ? col.index : i, }); } return schema; } /** * Confronta schema attuale e nuovo (PUT tabella). * - colonne senza id: aggiunta (solo schema) * - colonne con id e nome diverso: rename righe * - id assenti nel nuovo schema: delete campo dalle righe */ function resolveSchemaUpdate(incoming, currentSchema) { if (!Array.isArray(incoming)) throw new Error('schema must be an array'); const currentById = {}; for (let i = 0; i < currentSchema.length; i++) { currentById[currentSchema[i].id] = currentSchema[i]; } const newSchema = []; const names = {}; const renames = []; const remainingIds = Object.assign({}, currentById); for (let i = 0; i < incoming.length; i++) { const col = incoming[i]; if (!col || typeof col !== 'object') throw new Error('Invalid column at index ' + i); if (col.id && currentById[col.id]) { const prev = currentById[col.id]; delete remainingIds[col.id]; const name = typeof col.name === 'string' ? col.name.trim() : ''; assertValidColumnName(name, 'Invalid column at index ' + i); const type = col.type || prev.type; if (!type || COLUMN_TYPES.indexOf(type) === -1) { throw new Error('Column type is required at index ' + i); } if (names[name]) throw new Error('Column name already exists: ' + name); names[name] = true; if (name !== prev.name) { renames.push({ oldName: prev.name, newName: name }); } newSchema.push({ id: col.id, name: name, type: type, index: typeof col.index === 'number' ? col.index : i, }); } else if (!col.id) { const added = normalizeColumnToAdd(col, i, newSchema); if (names[added.name]) throw new Error('Column name already exists: ' + added.name); names[added.name] = true; newSchema.push(added); } else { throw new Error('Column not found: ' + col.id); } } const deletes = []; for (const id in remainingIds) { deletes.push(remainingIds[id].name); } return { schema: newSchema, renames: renames, deletes: deletes }; } function normalizeColumnToAdd(col, nextIndex, existingSchema) { if (!col || typeof col !== 'object') throw new Error('Invalid column'); if (col.id) throw new Error('Column id must not be provided; ids are generated by the server'); const name = typeof col.name === 'string' ? col.name.trim() : ''; assertValidColumnName(name); if (!col.type || COLUMN_TYPES.indexOf(col.type) === -1) throw new Error('Column type is required'); for (let i = 0; i < existingSchema.length; i++) { if (existingSchema[i].name === name) throw new Error('Column name already exists: ' + name); } return { id: generateColumnId(), name: name, type: col.type, index: typeof col.index === 'number' ? col.index : nextIndex, }; } function findColumn(schema, columnId) { for (let i = 0; i < schema.length; i++) { if (schema[i].id === columnId) return schema[i]; } return null; } function findColumnByName(schema, name) { for (let i = 0; i < schema.length; i++) { if (schema[i].name === name) return schema[i]; } return null; } function coerceValue(value, column) { if (value === null || value === undefined) return value; switch (column.type) { case 'string': if (typeof value !== 'string') throw new Error('Invalid value for column ' + column.name + ': expected string'); return value; case 'number': if (typeof value === 'number' && !isNaN(value)) return value; if (typeof value === 'string' && value.trim() !== '') { const num = Number(value); if (!isNaN(num)) return num; } throw new Error('Invalid value for column ' + column.name + ': expected number'); case 'boolean': if (typeof value === 'boolean') return value; if (typeof value === 'string') { const lower = value.trim().toLowerCase(); if (lower === 'true') return true; if (lower === 'false') return false; } throw new Error('Invalid value for column ' + column.name + ': expected boolean'); case 'datetime': if (value instanceof Date && !isNaN(value.getTime())) return value; if (typeof value === 'string' && value.length > 0) { const date = new Date(value); if (!isNaN(date.getTime())) return date; } throw new Error('Invalid value for column ' + column.name + ': expected datetime'); default: throw new Error('Unsupported column type: ' + column.type); } } function validateRowData(data, schema) { if (!data || typeof data !== 'object' || Array.isArray(data)) { throw new Error('data must be an object'); } const allowed = {}; for (let i = 0; i < schema.length; i++) allowed[schema[i].name] = schema[i]; for (const key in data) { if (!Object.prototype.hasOwnProperty.call(data, key)) continue; if (!allowed[key]) throw new Error('Column ' + key + ' does not exist'); } const result = {}; for (let j = 0; j < schema.length; j++) { const col = schema[j]; if (!Object.prototype.hasOwnProperty.call(data, col.name)) continue; const raw = data[col.name]; if (raw === undefined) continue; result[col.name] = coerceValue(raw, col); } return result; } function escapeRegex(str) { return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function buildSearchFilter(searchBody, schema) { if (searchBody.rowId) { return { _id: searchBody.rowId }; } if (!searchBody.conditions || !searchBody.conditions.length) { throw new Error('rowId or conditions is required'); } const match = searchBody.must_match || searchBody.match || 'all'; if (MATCH_MODES.indexOf(match) === -1) throw new Error('must_match must be "all" or "any"'); const clauses = []; for (let i = 0; i < searchBody.conditions.length; i++) { const item = searchBody.conditions[i]; if (!item || typeof item !== 'object') throw new Error('Invalid condition at index ' + i); const columnName = typeof item.column === 'string' ? item.column.trim() : ''; const colMeta = findColumnByName(schema, columnName); if (!colMeta) throw new Error('Column ' + item.column + ' does not exist'); const operator = item.operator; const field = 'data.' + colMeta.name; if (NO_VALUE_OPERATORS.indexOf(operator) !== -1) { if (operator === 'exists') { clauses.push({ [field]: { $exists: true, $ne: null } }); } else { clauses.push({ $or: [{ [field]: { $exists: false } }, { [field]: null }] }); } continue; } if (item.value === undefined) throw new Error('Missing value for condition at index ' + i); const value = coerceValue(item.value, colMeta); if (operator === 'equal') { clauses.push({ [field]: value }); } else if (operator === 'not_equal') { clauses.push({ [field]: { $ne: value } }); } else if (STRING_OPERATORS.indexOf(operator) !== -1) { if (colMeta.type !== 'string') throw new Error('Operator ' + operator + ' is only supported for string columns'); const escaped = escapeRegex(value); if (operator === 'contains') { clauses.push({ [field]: { $regex: escaped, $options: 'i' } }); } else if (operator === 'starts_with') { clauses.push({ [field]: { $regex: '^' + escaped, $options: 'i' } }); } else { clauses.push({ [field]: { $regex: escaped + '$', $options: 'i' } }); } } else if (OPERATORS[operator]) { if (colMeta.type === 'string') throw new Error('Operator ' + operator + ' is not supported for string columns'); clauses.push({ [field]: { [OPERATORS[operator]]: value } }); } else { throw new Error('Invalid operator: ' + operator); } } if (match === 'any') return { $or: clauses }; return { $and: clauses }; } /** Riempie ogni colonna dello schema; i campi assenti nella riga diventano null. */ function fillRowDataFromSchema(data, schema) { data = data || {}; const result = {}; for (let i = 0; i < schema.length; i++) { const name = schema[i].name; result[name] = Object.prototype.hasOwnProperty.call(data, name) ? data[name] : null; } return result; } function buildRowQueryFilter(body, schema) { if (body.id_row) { return { _id: body.id_row }; } if (body.conditions && body.conditions.length) { return buildSearchFilter({ conditions: body.conditions, must_match: body.must_match, match: body.match, }, schema); } throw new Error('id_row or conditions is required'); } function buildUpdateSet(data, schema) { if (!data || typeof data !== 'object' || Array.isArray(data)) { throw new Error('data is required'); } const validated = validateRowData(data, schema); const $set = {}; for (const key in validated) { if (Object.prototype.hasOwnProperty.call(validated, key)) { $set['data.' + key] = validated[key]; } } return { $set: $set }; } module.exports = { COLUMN_TYPES: COLUMN_TYPES, generateColumnId: generateColumnId, normalizeSchemaForCreate: normalizeSchemaForCreate, normalizeColumnToAdd: normalizeColumnToAdd, resolveSchemaUpdate: resolveSchemaUpdate, findColumn: findColumn, findColumnByName: findColumnByName, validateRowData: validateRowData, buildSearchFilter: buildSearchFilter, buildRowQueryFilter: buildRowQueryFilter, buildUpdateSet: buildUpdateSet, fillRowDataFromSchema: fillRowDataFromSchema, };