/** * MCP Outline PostgreSQL - Safe Query Builder * Helper for parameterized queries to prevent SQL injection * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { sanitizeInput, isValidUUID } from './security.js'; /** * Safe query builder with automatic parameter handling */ export class SafeQueryBuilder { private params: any[] = []; private paramIndex = 1; private conditions: string[] = []; /** * Add a parameter and return its placeholder */ addParam(value: any): string { this.params.push(value); return `$${this.paramIndex++}`; } /** * Get current parameter index (useful for manual queries) */ getNextIndex(): number { return this.paramIndex; } /** * Build ILIKE condition (case-insensitive search) */ buildILike(column: string, value: string): string { const sanitized = sanitizeInput(value); return `${column} ILIKE ${this.addParam(`%${sanitized}%`)}`; } /** * Build exact ILIKE condition (no wildcards) */ buildILikeExact(column: string, value: string): string { const sanitized = sanitizeInput(value); return `${column} ILIKE ${this.addParam(sanitized)}`; } /** * Build ILIKE condition with prefix match */ buildILikePrefix(column: string, value: string): string { const sanitized = sanitizeInput(value); return `${column} ILIKE ${this.addParam(`${sanitized}%`)}`; } /** * Build IN clause for array of values */ buildIn(column: string, values: any[]): string { if (values.length === 0) { return 'FALSE'; // Empty IN clause } return `${column} = ANY(${this.addParam(values)})`; } /** * Build NOT IN clause */ buildNotIn(column: string, values: any[]): string { if (values.length === 0) { return 'TRUE'; // Empty NOT IN clause } return `${column} != ALL(${this.addParam(values)})`; } /** * Build equals condition */ buildEquals(column: string, value: any): string { return `${column} = ${this.addParam(value)}`; } /** * Build not equals condition */ buildNotEquals(column: string, value: any): string { return `${column} != ${this.addParam(value)}`; } /** * Build greater than condition */ buildGreaterThan(column: string, value: any): string { return `${column} > ${this.addParam(value)}`; } /** * Build greater than or equals condition */ buildGreaterThanOrEquals(column: string, value: any): string { return `${column} >= ${this.addParam(value)}`; } /** * Build less than condition */ buildLessThan(column: string, value: any): string { return `${column} < ${this.addParam(value)}`; } /** * Build less than or equals condition */ buildLessThanOrEquals(column: string, value: any): string { return `${column} <= ${this.addParam(value)}`; } /** * Build BETWEEN condition */ buildBetween(column: string, from: any, to: any): string { return `${column} BETWEEN ${this.addParam(from)} AND ${this.addParam(to)}`; } /** * Build IS NULL condition */ buildIsNull(column: string): string { return `${column} IS NULL`; } /** * Build IS NOT NULL condition */ buildIsNotNull(column: string): string { return `${column} IS NOT NULL`; } /** * Build UUID equals condition with validation */ buildUUIDEquals(column: string, uuid: string): string { if (!isValidUUID(uuid)) { throw new Error(`Invalid UUID: ${uuid}`); } return `${column} = ${this.addParam(uuid)}`; } /** * Build UUID IN clause with validation */ buildUUIDIn(column: string, uuids: string[]): string { for (const uuid of uuids) { if (!isValidUUID(uuid)) { throw new Error(`Invalid UUID: ${uuid}`); } } return this.buildIn(column, uuids); } /** * Add a condition to the internal conditions array */ addCondition(condition: string): this { this.conditions.push(condition); return this; } /** * Add a condition if value is truthy */ addConditionIf(condition: string, value: any): this { if (value !== undefined && value !== null && value !== '') { this.conditions.push(condition); } return this; } /** * Build WHERE clause from accumulated conditions */ buildWhereClause(separator = ' AND '): string { if (this.conditions.length === 0) { return ''; } return `WHERE ${this.conditions.join(separator)}`; } /** * Get all parameters */ getParams(): any[] { return this.params; } /** * Get conditions array */ getConditions(): string[] { return this.conditions; } /** * Reset builder state */ reset(): this { this.params = []; this.paramIndex = 1; this.conditions = []; return this; } /** * Clone builder (useful for subqueries) */ clone(): SafeQueryBuilder { const clone = new SafeQueryBuilder(); clone.params = [...this.params]; clone.paramIndex = this.paramIndex; clone.conditions = [...this.conditions]; return clone; } } /** * Create a new SafeQueryBuilder instance */ export function createQueryBuilder(): SafeQueryBuilder { return new SafeQueryBuilder(); } /** * Build a simple SELECT query */ export function buildSelectQuery( table: string, columns: string[], builder: SafeQueryBuilder, options?: { orderBy?: string; orderDirection?: 'ASC' | 'DESC'; limit?: number; offset?: number; } ): { query: string; params: any[] } { const columnsStr = columns.join(', '); const whereClause = builder.buildWhereClause(); let query = `SELECT ${columnsStr} FROM ${table} ${whereClause}`; if (options?.orderBy) { const direction = options.orderDirection || 'DESC'; query += ` ORDER BY ${options.orderBy} ${direction}`; } if (options?.limit) { query += ` LIMIT ${builder.addParam(options.limit)}`; } if (options?.offset) { query += ` OFFSET ${builder.addParam(options.offset)}`; } return { query, params: builder.getParams() }; } /** * Build a COUNT query */ export function buildCountQuery( table: string, builder: SafeQueryBuilder ): { query: string; params: any[] } { const whereClause = builder.buildWhereClause(); const query = `SELECT COUNT(*) as count FROM ${table} ${whereClause}`; return { query, params: builder.getParams() }; }