528 lines
14 KiB
JavaScript
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,
|
|
};
|
|
|