/** * QueryBuilder - Translates Parse-style queries to SQLite SQL * * Parse uses operators like $eq, $ne, $gt, $lt, $in, etc. * This translates them to SQL WHERE clauses. * * @module adapters/local-sql/QueryBuilder */ /** * Reserved SQLite keywords that need to be escaped */ const RESERVED_WORDS = new Set([ 'order', 'group', 'select', 'from', 'where', 'index', 'table', 'create', 'drop', 'alter', 'delete', 'insert', 'update', 'key', 'primary', 'foreign', 'references', 'null', 'not', 'and', 'or', 'in', 'like', 'between', 'is', 'exists', 'case', 'when', 'then', 'else', 'end', 'join', 'inner', 'outer', 'left', 'right', 'on', 'as', 'asc', 'desc', 'limit', 'offset', 'union', 'distinct', 'all', 'any', 'some', 'true', 'false', 'default', 'values', 'set', 'into', 'by', 'having', 'count', 'sum', 'avg', 'min', 'max' ]); /** * Escape a table name for SQL * @param {string} name * @returns {string} */ function escapeTable(name) { // Sanitize: only allow alphanumeric and underscore const sanitized = name.replace(/[^a-zA-Z0-9_]/g, ''); return `"${sanitized}"`; } /** * Escape a column name for SQL * @param {string} name * @returns {string} */ function escapeColumn(name) { // Sanitize: only allow alphanumeric and underscore const sanitized = name.replace(/[^a-zA-Z0-9_]/g, ''); // Always quote to handle reserved words return `"${sanitized}"`; } /** * Convert a Parse Date object to ISO string for SQLite * @param {Object|Date|string} value * @returns {string} */ function convertDateValue(value) { if (value && value.__type === 'Date' && value.iso) { return value.iso; } if (value instanceof Date) { return value.toISOString(); } return value; } /** * Convert a Parse Pointer to its objectId * @param {Object|string} value * @returns {string} */ function convertPointerValue(value) { if (value && value.__type === 'Pointer' && value.objectId) { return value.objectId; } return value; } /** * Build a WHERE clause from a Parse-style query * * @param {Object} where - Parse-style query object * @param {Array} params - Array to push parameter values to * @param {Object} [schema] - Optional schema for type-aware conversion * @returns {string} SQL WHERE clause (without "WHERE" keyword) */ function buildWhereClause(where, params, schema) { if (!where || Object.keys(where).length === 0) { return ''; } const conditions = []; for (const [key, condition] of Object.entries(where)) { // Handle logical operators if (key === '$and' && Array.isArray(condition)) { const subConditions = condition.map((sub) => buildWhereClause(sub, params, schema)).filter((c) => c); if (subConditions.length > 0) { conditions.push(`(${subConditions.join(' AND ')})`); } continue; } if (key === '$or' && Array.isArray(condition)) { const subConditions = condition.map((sub) => buildWhereClause(sub, params, schema)).filter((c) => c); if (subConditions.length > 0) { conditions.push(`(${subConditions.join(' OR ')})`); } continue; } // Handle $relatedTo - this is tricky with SQLite if (key === '$relatedTo') { // For relations, we need a subquery on the junction table const { object, key: relationKey } = condition; if (object && object.objectId && object.className && relationKey) { const junctionTable = `_Join_${relationKey}_${object.className}`; conditions.push(`"objectId" IN (SELECT "relatedId" FROM ${escapeTable(junctionTable)} WHERE "owningId" = ?)`); params.push(object.objectId); } continue; } // Handle field conditions const col = escapeColumn(key); if (typeof condition !== 'object' || condition === null) { // Direct equality conditions.push(`${col} = ?`); params.push(convertDateValue(convertPointerValue(condition))); continue; } // Handle Parse operators for (const [op, value] of Object.entries(condition)) { const sqlCondition = translateOperator(col, op, value, params, schema); if (sqlCondition) { conditions.push(sqlCondition); } } } return conditions.join(' AND '); } /** * Translate a single Parse operator to SQL * * @param {string} col - Escaped column name * @param {string} op - Parse operator ($eq, $ne, etc.) * @param {*} value - Comparison value * @param {Array} params - Parameters array to push values to * @param {Object} [schema] - Optional schema * @returns {string|null} SQL condition or null */ function translateOperator(col, op, value, params, schema) { // Convert special types const convertedValue = convertDateValue(convertPointerValue(value)); switch (op) { case '$eq': if (convertedValue === null) { return `${col} IS NULL`; } params.push(convertedValue); return `${col} = ?`; case '$ne': if (convertedValue === null) { return `${col} IS NOT NULL`; } params.push(convertedValue); return `${col} != ?`; case '$gt': params.push(convertedValue); return `${col} > ?`; case '$gte': params.push(convertedValue); return `${col} >= ?`; case '$lt': params.push(convertedValue); return `${col} < ?`; case '$lte': params.push(convertedValue); return `${col} <= ?`; case '$in': if (!Array.isArray(value) || value.length === 0) { return '0'; // Always false } const inValues = value.map((v) => convertDateValue(convertPointerValue(v))); const placeholders = inValues.map(() => '?').join(', '); params.push(...inValues); return `${col} IN (${placeholders})`; case '$nin': if (!Array.isArray(value) || value.length === 0) { return '1'; // Always true (not in empty set) } const ninValues = value.map((v) => convertDateValue(convertPointerValue(v))); const ninPlaceholders = ninValues.map(() => '?').join(', '); params.push(...ninValues); return `${col} NOT IN (${ninPlaceholders})`; case '$exists': return value ? `${col} IS NOT NULL` : `${col} IS NULL`; case '$regex': // SQLite doesn't have native regex, use LIKE with % wildcards // This is a simplification - only handles basic patterns params.push(`%${value}%`); return `${col} LIKE ?`; case '$options': // This is used with $regex, ignore here return null; case '$text': // Full text search - convert to LIKE if (value && value.$search) { const term = typeof value.$search === 'string' ? value.$search : value.$search.$term || ''; params.push(`%${term}%`); return `${col} LIKE ?`; } return null; case 'contains': case '$contains': // Contains search - convert to LIKE with wildcards params.push(`%${convertedValue}%`); return `${col} LIKE ?`; // Geo queries - not fully supported in SQLite without extensions case '$nearSphere': case '$within': case '$geoWithin': console.warn(`Geo query operator ${op} not supported in SQLite adapter`); return null; default: console.warn(`Unknown query operator: ${op}`); return null; } } /** * Build ORDER BY clause from Parse-style sort * * @param {string|string[]} sort - Sort specification (e.g., 'name' or '-createdAt' for desc) * @returns {string} SQL ORDER BY clause (without "ORDER BY" keyword) */ function buildOrderClause(sort) { if (!sort) { return ''; } const sortArray = Array.isArray(sort) ? sort : sort.split(','); const orders = sortArray.map((s) => { const trimmed = s.trim(); if (trimmed.startsWith('-')) { return `${escapeColumn(trimmed.substring(1))} DESC`; } return `${escapeColumn(trimmed)} ASC`; }); return orders.join(', '); } /** * Build a SELECT query * * @param {Object} options - Query options * @param {string} options.collection - Collection name * @param {Object} [options.where] - Parse-style query filter * @param {string|string[]} [options.select] - Fields to select * @param {string|string[]} [options.sort] - Sort order * @param {number} [options.limit] - Max records * @param {number} [options.skip] - Records to skip * @param {Object} [schema] - Optional schema * @returns {{ sql: string, params: Array }} */ function buildSelect(options, schema) { const params = []; const table = escapeTable(options.collection); // Build SELECT clause let selectClause = '*'; if (options.select) { const selectArray = Array.isArray(options.select) ? options.select : options.select.split(','); // Always include id const fields = new Set(['id', ...selectArray.map((s) => s.trim())]); selectClause = Array.from(fields) .map((f) => escapeColumn(f)) .join(', '); } let sql = `SELECT ${selectClause} FROM ${table}`; // Build WHERE clause if (options.where) { const whereClause = buildWhereClause(options.where, params, schema); if (whereClause) { sql += ` WHERE ${whereClause}`; } } // Build ORDER BY clause if (options.sort) { const orderClause = buildOrderClause(options.sort); if (orderClause) { sql += ` ORDER BY ${orderClause}`; } } // Build LIMIT/OFFSET if (options.limit !== undefined) { sql += ' LIMIT ?'; params.push(options.limit); } if (options.skip !== undefined && options.skip > 0) { sql += ' OFFSET ?'; params.push(options.skip); } return { sql, params }; } /** * Build a COUNT query * * @param {Object} options * @param {string} options.collection * @param {Object} [options.where] * @param {Object} [schema] * @returns {{ sql: string, params: Array }} */ function buildCount(options, schema) { const params = []; const table = escapeTable(options.collection); let sql = `SELECT COUNT(*) as count FROM ${table}`; if (options.where) { const whereClause = buildWhereClause(options.where, params, schema); if (whereClause) { sql += ` WHERE ${whereClause}`; } } return { sql, params }; } /** * Build an INSERT query * * @param {Object} options * @param {string} options.collection * @param {Object} options.data * @param {string} objectId * @returns {{ sql: string, params: Array }} */ function buildInsert(options, id) { const params = []; const table = escapeTable(options.collection); const now = new Date().toISOString(); const data = { id, createdAt: now, updatedAt: now, ...options.data }; // Remove protected fields delete data._createdAt; delete data._updatedAt; const columns = []; const placeholders = []; for (const [key, value] of Object.entries(data)) { columns.push(escapeColumn(key)); placeholders.push('?'); params.push(serializeValue(value)); } const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`; return { sql, params }; } /** * Build an UPDATE query * * @param {Object} options * @param {string} options.collection * @param {string} options.id - Record ID * @param {Object} options.data * @returns {{ sql: string, params: Array }} */ function buildUpdate(options) { const params = []; const table = escapeTable(options.collection); const data = { ...options.data }; // Add updatedAt data.updatedAt = new Date().toISOString(); // Remove protected fields delete data.id; delete data.createdAt; delete data._createdAt; delete data._updatedAt; const setClause = []; for (const [key, value] of Object.entries(data)) { setClause.push(`${escapeColumn(key)} = ?`); params.push(serializeValue(value)); } // Use id or objectId for backwards compatibility const recordId = options.id || options.objectId; params.push(recordId); const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`; return { sql, params }; } /** * Build a DELETE query * * @param {Object} options * @param {string} options.collection * @param {string} options.id - Record ID * @returns {{ sql: string, params: Array }} */ function buildDelete(options) { const table = escapeTable(options.collection); // Use id or objectId for backwards compatibility const recordId = options.id || options.objectId; const sql = `DELETE FROM ${table} WHERE "id" = ?`; return { sql, params: [recordId] }; } /** * Build an INCREMENT query * * @param {Object} options * @param {string} options.collection * @param {string} options.id - Record ID * @param {Object} options.properties * @returns {{ sql: string, params: Array }} */ function buildIncrement(options) { const params = []; const table = escapeTable(options.collection); const setClause = []; for (const [key, amount] of Object.entries(options.properties)) { const col = escapeColumn(key); setClause.push(`${col} = COALESCE(${col}, 0) + ?`); params.push(amount); } // Add updatedAt setClause.push('"updatedAt" = ?'); params.push(new Date().toISOString()); // Use id or objectId for backwards compatibility const recordId = options.id || options.objectId; params.push(recordId); const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`; return { sql, params }; } /** * Build a DISTINCT query * * @param {Object} options * @param {string} options.collection * @param {string} options.property * @param {Object} [options.where] * @returns {{ sql: string, params: Array }} */ function buildDistinct(options) { const params = []; const table = escapeTable(options.collection); const col = escapeColumn(options.property); let sql = `SELECT DISTINCT ${col} FROM ${table}`; if (options.where) { const whereClause = buildWhereClause(options.where, params); if (whereClause) { sql += ` WHERE ${whereClause}`; } } return { sql, params }; } /** * Build an AGGREGATE query * * @param {Object} options * @param {string} options.collection * @param {Object} [options.where] * @param {Object} options.group - Grouping config with avg/sum/max/min/distinct * @param {number} [options.limit] * @param {number} [options.skip] * @returns {{ sql: string, params: Array }} */ function buildAggregate(options) { const params = []; const table = escapeTable(options.collection); const selectParts = []; for (const [alias, groupConfig] of Object.entries(options.group)) { if (groupConfig.avg !== undefined) { selectParts.push(`AVG(${escapeColumn(groupConfig.avg)}) as ${escapeColumn(alias)}`); } else if (groupConfig.sum !== undefined) { selectParts.push(`SUM(${escapeColumn(groupConfig.sum)}) as ${escapeColumn(alias)}`); } else if (groupConfig.max !== undefined) { selectParts.push(`MAX(${escapeColumn(groupConfig.max)}) as ${escapeColumn(alias)}`); } else if (groupConfig.min !== undefined) { selectParts.push(`MIN(${escapeColumn(groupConfig.min)}) as ${escapeColumn(alias)}`); } else if (groupConfig.distinct !== undefined) { // COUNT DISTINCT as alternative to $addToSet selectParts.push(`COUNT(DISTINCT ${escapeColumn(groupConfig.distinct)}) as ${escapeColumn(alias)}`); } } if (selectParts.length === 0) { return { sql: `SELECT COUNT(*) as count FROM ${table}`, params: [] }; } let sql = `SELECT ${selectParts.join(', ')} FROM ${table}`; if (options.where) { const whereClause = buildWhereClause(options.where, params); if (whereClause) { sql += ` WHERE ${whereClause}`; } } return { sql, params }; } /** * Serialize a JavaScript value for SQLite storage * * @param {*} value * @returns {*} */ function serializeValue(value) { if (value === null || value === undefined) { return null; } // Handle Parse types if (value && typeof value === 'object') { // Date type if (value.__type === 'Date' && value.iso) { return value.iso; } // Pointer type - store just the objectId if (value.__type === 'Pointer' && value.objectId) { return value.objectId; } // File type - store as JSON if (value.__type === 'File') { return JSON.stringify(value); } // GeoPoint type - store as JSON if (value.__type === 'GeoPoint') { return JSON.stringify(value); } // Arrays and objects - store as JSON if (Array.isArray(value) || Object.keys(value).length > 0) { return JSON.stringify(value); } } // Handle Date objects if (value instanceof Date) { return value.toISOString(); } // Handle booleans - SQLite uses 0/1 if (typeof value === 'boolean') { return value ? 1 : 0; } return value; } /** * Deserialize a SQLite value back to JavaScript * * @param {*} value * @param {string} [type] - Expected type from schema * @returns {*} */ function deserializeValue(value, type) { if (value === null || value === undefined) { return null; } // Handle type-based deserialization if (type === 'Boolean') { return Boolean(value); } if (type === 'Date') { return value; // Keep as ISO string, let CloudStore handle Date objects } if (type === 'Object' || type === 'Array' || type === 'GeoPoint' || type === 'File') { if (typeof value === 'string') { try { return JSON.parse(value); } catch (e) { return value; } } } // Try to parse JSON strings that look like objects/arrays if (typeof value === 'string') { if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) { try { return JSON.parse(value); } catch (e) { // Not valid JSON, return as-is } } } return value; } module.exports = { escapeTable, escapeColumn, buildWhereClause, buildOrderClause, buildSelect, buildCount, buildInsert, buildUpdate, buildDelete, buildIncrement, buildDistinct, buildAggregate, serializeValue, deserializeValue };