@tiledesk/tiledesk-server
Version:
The Tiledesk server module
354 lines (297 loc) • 11.6 kB
JavaScript
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,
};