/** * Centralized JSON-based Database Query Helper * * This helper provides a safe, SQL-string-free interface for database operations. * All queries are built using Knex.js query builder with parameterized queries. * * @module db/queryHelper */ import db from './knex.js'; // Whitelist of allowed table names (security: prevent SQL injection via table names) const ALLOWED_TABLES = new Set([ 'users', 'listings', 'animals', 'locations', 'species', 'breeds', 'listing_media', 'conversations', 'messages', 'communication_records', 'favorites', 'user_devices', 'subscription_plans', 'otp_requests', 'auth_audit', ]); // Maximum limit to prevent resource exhaustion const MAX_LIMIT = 100; /** * Validates table name against whitelist * @param {string} table - Table name to validate * @throws {Error} If table is not in whitelist */ function validateTable(table) { if (!table || typeof table !== 'string') { throw new Error('Table name is required and must be a string'); } if (!ALLOWED_TABLES.has(table)) { throw new Error(`Table '${table}' is not allowed. Allowed tables: ${Array.from(ALLOWED_TABLES).join(', ')}`); } } /** * Applies WHERE conditions from JSON object to Knex query builder * Supports simple equality, operators, and complex conditions * * @param {object} queryBuilder - Knex query builder instance * @param {object} where - WHERE conditions object * * @example * // Simple equality * { status: 'active' } → WHERE status = 'active' * * @example * // Operators * { price: { op: '>', value: 1000 } } → WHERE price > 1000 * { age: { op: 'in', value: [18, 19, 20] } } → WHERE age IN (18, 19, 20) * * @example * // Multiple conditions (AND) * { status: 'active', deleted: false } → WHERE status = 'active' AND deleted = false */ function applyWhereConditions(queryBuilder, where) { if (!where || typeof where !== 'object') { return queryBuilder; } for (const [column, condition] of Object.entries(where)) { if (condition === null || condition === undefined) { // Handle null checks queryBuilder.whereNull(column); } else if (typeof condition === 'object' && condition.op) { // Handle operators: { op: '>', value: 100 } const { op, value } = condition; switch (op) { case '>': queryBuilder.where(column, '>', value); break; case '<': queryBuilder.where(column, '<', value); break; case '>=': queryBuilder.where(column, '>=', value); break; case '<=': queryBuilder.where(column, '<=', value); break; case '!=': case '<>': queryBuilder.where(column, '!=', value); break; case 'in': if (Array.isArray(value)) { queryBuilder.whereIn(column, value); } else { throw new Error(`Operator 'in' requires an array value, got ${typeof value}`); } break; case 'notIn': if (Array.isArray(value)) { queryBuilder.whereNotIn(column, value); } else { throw new Error(`Operator 'notIn' requires an array value, got ${typeof value}`); } break; case 'like': queryBuilder.where(column, 'like', value); break; case 'ilike': queryBuilder.where(column, 'ilike', value); break; case 'between': if (Array.isArray(value) && value.length === 2) { queryBuilder.whereBetween(column, value); } else { throw new Error(`Operator 'between' requires an array of 2 values, got ${JSON.stringify(value)}`); } break; case 'isNull': queryBuilder.whereNull(column); break; case 'isNotNull': queryBuilder.whereNotNull(column); break; default: throw new Error(`Unsupported operator: ${op}. Supported: >, <, >=, <=, !=, in, notIn, like, ilike, between, isNull, isNotNull`); } } else { // Simple equality: { status: 'active' } queryBuilder.where(column, condition); } } return queryBuilder; } /** * SELECT query - Retrieve records from database * * @param {object} options - Query options * @param {string} options.table - Table name (must be in whitelist) * @param {string[]|string} [options.columns='*'] - Columns to select * @param {object} [options.where] - WHERE conditions (JSON object) * @param {object} [options.orderBy] - Order by configuration * @param {string} options.orderBy.column - Column to order by * @param {string} [options.orderBy.direction='asc'] - 'asc' or 'desc' * @param {number} [options.limit] - Maximum number of records (capped at MAX_LIMIT) * @param {number} [options.offset=0] - Number of records to skip * @param {object[]} [options.joins] - Join configurations * @param {string} options.joins[].type - 'inner', 'left', 'right', 'full' * @param {string} options.joins[].table - Table to join * @param {string} options.joins[].on - Join condition (e.g., 'listings.animal_id = animals.id') * * @returns {Promise} Array of records * * @example * // Simple select * const users = await select({ * table: 'users', * where: { deleted: false }, * limit: 10 * }); * * @example * // With operators * const listings = await select({ * table: 'listings', * where: { * status: 'active', * price: { op: '>=', value: 1000 }, * age: { op: 'in', value: [18, 19, 20] } * }, * orderBy: { column: 'created_at', direction: 'desc' }, * limit: 20 * }); */ export async function select(options) { const { table, columns = '*', where, orderBy, limit, offset = 0, joins } = options; validateTable(table); let query = db(table); // Select columns if (Array.isArray(columns)) { query = query.select(columns); } else if (columns === '*') { query = query.select('*'); } else { throw new Error('Columns must be an array or "*"'); } // Apply joins if (joins && Array.isArray(joins)) { for (const join of joins) { const { type = 'inner', table: joinTable, on } = join; if (!joinTable || !on) { throw new Error('Join must have table and on properties'); } switch (type.toLowerCase()) { case 'inner': query = query.innerJoin(joinTable, on); break; case 'left': query = query.leftJoin(joinTable, on); break; case 'right': query = query.rightJoin(joinTable, on); break; case 'full': query = query.fullOuterJoin(joinTable, on); break; default: throw new Error(`Unsupported join type: ${type}`); } } } // Apply WHERE conditions query = applyWhereConditions(query, where); // Apply ORDER BY if (orderBy) { const { column, direction = 'asc' } = orderBy; if (!column) { throw new Error('orderBy.column is required'); } if (direction !== 'asc' && direction !== 'desc') { throw new Error('orderBy.direction must be "asc" or "desc"'); } query = query.orderBy(column, direction); } // Apply LIMIT (with cap) if (limit !== undefined) { const cappedLimit = Math.min(limit, MAX_LIMIT); query = query.limit(cappedLimit); } // Apply OFFSET if (offset > 0) { query = query.offset(offset); } const results = await query; return results; } /** * INSERT query - Insert new record(s) * * @param {object} options - Insert options * @param {string} options.table - Table name (must be in whitelist) * @param {object|object[]} options.data - Data to insert (single object or array for batch) * @param {string[]} [options.returning] - Columns to return (PostgreSQL) * * @returns {Promise} Inserted record(s) * * @example * // Single insert * const user = await insert({ * table: 'users', * data: { * name: 'John Doe', * phone_number: '+1234567890' * }, * returning: ['id', 'name'] * }); * * @example * // Batch insert * const media = await insert({ * table: 'listing_media', * data: [ * { listing_id: 'uuid1', media_url: 'url1', media_type: 'image' }, * { listing_id: 'uuid2', media_url: 'url2', media_type: 'image' } * ] * }); */ export async function insert(options) { const { table, data, returning } = options; validateTable(table); if (!data || (typeof data !== 'object' && !Array.isArray(data))) { throw new Error('Data is required and must be an object or array'); } let query = db(table); if (Array.isArray(data)) { // Batch insert query = query.insert(data); } else { // Single insert query = query.insert(data); } // PostgreSQL RETURNING clause if (returning && Array.isArray(returning)) { query = query.returning(returning); } else if (returning === '*') { query = query.returning('*'); } const results = await query; return Array.isArray(data) ? results : (results[0] || results); } /** * UPDATE query - Update existing records * * @param {object} options - Update options * @param {string} options.table - Table name (must be in whitelist) * @param {object} options.data - Data to update * @param {object} options.where - WHERE conditions (REQUIRED for safety) * @param {string[]} [options.returning] - Columns to return (PostgreSQL) * * @returns {Promise} Updated records * * @throws {Error} If where clause is missing (safety requirement) * * @example * const updated = await update({ * table: 'users', * data: { name: 'Jane Doe' }, * where: { id: 'user-uuid' }, * returning: ['id', 'name'] * }); * * @example * // Update with operators * const updated = await update({ * table: 'listings', * data: { status: 'sold' }, * where: { * seller_id: 'seller-uuid', * status: { op: '!=', value: 'sold' } * } * }); */ export async function update(options) { const { table, data, where, returning } = options; validateTable(table); if (!data || typeof data !== 'object') { throw new Error('Data is required and must be an object'); } if (!where || typeof where !== 'object' || Object.keys(where).length === 0) { throw new Error('WHERE clause is required for UPDATE operations (safety requirement)'); } let query = db(table).update(data); // Apply WHERE conditions query = applyWhereConditions(query, where); // PostgreSQL RETURNING clause if (returning && Array.isArray(returning)) { query = query.returning(returning); } else if (returning === '*') { query = query.returning('*'); } const results = await query; return results; } /** * DELETE query - Delete records (soft delete recommended) * * @param {object} options - Delete options * @param {string} options.table - Table name (must be in whitelist) * @param {object} options.where - WHERE conditions (REQUIRED for safety) * @param {string[]} [options.returning] - Columns to return (PostgreSQL) * * @returns {Promise} Deleted records * * @throws {Error} If where clause is missing (safety requirement) * * @example * const deleted = await deleteRecord({ * table: 'users', * where: { id: 'user-uuid' }, * returning: ['id'] * }); * * @example * // Soft delete (recommended) * await update({ * table: 'users', * data: { deleted: true }, * where: { id: 'user-uuid' } * }); */ export async function deleteRecord(options) { const { table, where, returning } = options; validateTable(table); if (!where || typeof where !== 'object' || Object.keys(where).length === 0) { throw new Error('WHERE clause is required for DELETE operations (safety requirement)'); } let query = db(table).delete(); // Apply WHERE conditions query = applyWhereConditions(query, where); // PostgreSQL RETURNING clause if (returning && Array.isArray(returning)) { query = query.returning(returning); } else if (returning === '*') { query = query.returning('*'); } const results = await query; return results; } /** * EXECUTE - Execute custom query builder logic or transactions * * @param {object} options - Execute options * @param {string} options.type - 'transaction' or 'raw-builder' * @param {Function} options.handler - Handler function * @param {object} options.handler.knexInstance - Knex query builder or transaction instance * * @returns {Promise} Result from handler function * * @example * // Transaction * const result = await execute({ * type: 'transaction', * handler: async (trx) => { * const user = await trx('users').insert({ name: 'John' }).returning('*'); * await trx('listings').insert({ seller_id: user[0].id, title: 'Test' }); * return user; * } * }); * * @example * // Raw builder (for complex queries) * const result = await execute({ * type: 'raw-builder', * handler: async (knex) => { * return await knex('listings') * .select('listings.*', 'animals.*') * .join('animals', 'listings.animal_id', 'animals.id') * .where('listings.deleted', false); * } * }); */ export async function execute(options) { const { type, handler } = options; if (!handler || typeof handler !== 'function') { throw new Error('Handler function is required'); } if (type === 'transaction') { return await db.transaction(async (trx) => { return await handler(trx); }); } else if (type === 'raw-builder') { return await handler(db); } else { throw new Error(`Unsupported execute type: ${type}. Use 'transaction' or 'raw-builder'`); } } /** * Get a single record by ID * Convenience method for common use case * * @param {string} table - Table name * @param {string} id - Record ID * @param {string[]} [columns] - Columns to select * @returns {Promise} Record or null if not found */ export async function findById(table, id, columns = '*') { const results = await select({ table, columns, where: { id }, limit: 1, }); return results.length > 0 ? results[0] : null; } /** * Count records matching conditions * * @param {string} table - Table name * @param {object} [where] - WHERE conditions * @returns {Promise} Count of records */ export async function count(table, where = {}) { validateTable(table); let query = db(table); query = applyWhereConditions(query, where); const result = await query.count('* as count').first(); return parseInt(result.count, 10); } // Export all methods export default { select, insert, update, delete: deleteRecord, execute, findById, count, };