api-v1/db/queryHelper/index.js

528 lines
14 KiB
JavaScript

/**
* 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<object[]>} 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<object|object[]>} 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<object[]>} 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<object[]>} 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<any>} 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<object|null>} 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<number>} 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,
};