UNPKG

@wearesage/schema

Version:

A flexible schema definition and validation system for TypeScript with multi-database support

637 lines (560 loc) 19.6 kB
import { QueryBuilder } from "./QueryBuilder"; import { QueryCondition, QueryOperator, CompiledQuery, IQueryBuilder } from "./types"; /** * QueryCompiler - translates fluent queries into database-specific queries * This is the bridge between our universal API and specific database dialects */ export class QueryCompiler { private static queryCache = new Map<string, CompiledQuery>(); private static cacheSize = 1000; /** * Clear the query cache */ static clearCache(): void { this.queryCache.clear(); } /** * Get cache statistics */ static getCacheStats(): { size: number; hitRate: number } { return { size: this.queryCache.size, hitRate: 0 // TODO: Implement hit rate tracking }; } /** * Compile a QueryBuilder into database-specific queries */ static compile<T>(queryBuilder: IQueryBuilder<T>, targetDatabase: 'postgresql' | 'mongodb' | 'neo4j' | 'redis'): CompiledQuery { // Generate cache key based on query structure const cacheKey = this.generateCacheKey(queryBuilder, targetDatabase); // Check cache first const cached = this.queryCache.get(cacheKey); if (cached) { return cached; } // Optimize query before compilation const optimizedQuery = this.optimizeQuery(queryBuilder); let compiled: CompiledQuery; switch (targetDatabase) { case 'postgresql': compiled = this.compileToSQL(optimizedQuery); break; case 'mongodb': compiled = this.compileToMongoDB(optimizedQuery); break; case 'neo4j': compiled = this.compileToNeo4j(optimizedQuery); break; case 'redis': compiled = this.compileToRedis(optimizedQuery); break; default: throw new Error(`Unsupported database: ${targetDatabase}`); } // Cache the result this.cacheCompiledQuery(cacheKey, compiled); return compiled; } /** * Generate a cache key for a query */ private static generateCacheKey<T>(queryBuilder: IQueryBuilder<T>, targetDatabase: string): string { const conditions = queryBuilder.getConditions(); const sorts = queryBuilder.getSorts(); const limit = queryBuilder.getLimit(); const offset = queryBuilder.getOffset(); return `${targetDatabase}:${JSON.stringify({ conditions: conditions.map(c => ({ property: c.property, operator: c.operator })), sorts, limit, offset })}`; } /** * Cache a compiled query with LRU eviction */ private static cacheCompiledQuery(key: string, compiled: CompiledQuery): void { if (this.queryCache.size >= this.cacheSize) { // Remove oldest entry (simple LRU) const firstKey = this.queryCache.keys().next().value; if (firstKey !== undefined) { this.queryCache.delete(firstKey); } } this.queryCache.set(key, compiled); } /** * Optimize query conditions before compilation */ private static optimizeQuery<T>(queryBuilder: IQueryBuilder<T>): IQueryBuilder<T> { // Create a copy to avoid mutating the original const optimized = Object.create(queryBuilder); // Merge redundant conditions const conditions = this.mergeConditions(queryBuilder.getConditions()); // Override getConditions to return optimized conditions optimized.getConditions = () => conditions; return optimized; } /** * Merge redundant conditions (e.g., multiple equals on same property) */ private static mergeConditions(conditions: QueryCondition[]): QueryCondition[] { const merged: QueryCondition[] = []; const propertyMap = new Map<string, QueryCondition[]>(); // Group by property for (const condition of conditions) { const key = condition.property; if (!propertyMap.has(key)) { propertyMap.set(key, []); } propertyMap.get(key)!.push(condition); } // Merge conditions for each property for (const [property, propConditions] of propertyMap) { if (propConditions.length === 1) { merged.push(propConditions[0]); } else { // Try to merge multiple conditions const mergedCondition = this.mergePropertyConditions(propConditions); merged.push(...mergedCondition); } } return merged; } /** * Merge conditions for a single property */ private static mergePropertyConditions(conditions: QueryCondition[]): QueryCondition[] { // For now, just return as-is. Future optimization: // - Merge multiple 'equals' into 'in' // - Combine range conditions (gt + lt = between) // - Remove contradictory conditions return conditions; } /** * Compile to PostgreSQL with optimizations */ private static compileToSQL<T>(queryBuilder: IQueryBuilder<T>): CompiledQuery { const conditions = queryBuilder.getConditions(); const sorts = queryBuilder.getSorts(); const includes = queryBuilder.getIncludes(); const limit = queryBuilder.getLimit(); const offset = queryBuilder.getOffset(); let sql = 'SELECT * FROM entities'; const parameters: Record<string, any> = {}; let paramCount = 1; // Add query hints for optimization const hints = this.generateSQLHints(conditions, sorts, limit); if (hints.length > 0) { sql += ` /*+ ${hints.join(' ')} */`; } // WHERE clause with optimized condition ordering if (conditions.length > 0) { const optimizedConditions = this.optimizeSQLConditions(conditions); const whereClause = optimizedConditions.map(condition => { const paramName = `param${paramCount++}`; parameters[paramName] = condition.value; return this.compileConditionToSQL(condition, paramName); }).join(' AND '); sql += ` WHERE ${whereClause}`; } // ORDER BY clause if (sorts.length > 0) { const orderClause = sorts.map(sort => `${sort.property} ${sort.direction.toUpperCase()}`).join(', '); sql += ` ORDER BY ${orderClause}`; } // LIMIT and OFFSET if (limit !== undefined) { sql += ` LIMIT ${limit}`; } if (offset !== undefined) { sql += ` OFFSET ${offset}`; } return { sql, parameters }; } /** * Generate SQL performance hints */ private static generateSQLHints(conditions: QueryCondition[], sorts: any[], limit?: number): string[] { const hints: string[] = []; // Use index hints for equality conditions const equalityConditions = conditions.filter(c => c.operator === 'equals' || c.operator === 'eq'); if (equalityConditions.length > 0) { hints.push(`USE_INDEX(entities_${equalityConditions[0].property}_idx)`); } // Sort optimization hint if (sorts.length > 0 && limit && limit <= 1000) { hints.push('USE_INDEX_FOR_ORDER_BY'); } return hints; } /** * Optimize SQL condition ordering (most selective first) */ private static optimizeSQLConditions(conditions: QueryCondition[]): QueryCondition[] { return conditions.sort((a, b) => { // Equality conditions first (most selective) if (a.operator === 'equals' && b.operator !== 'equals') return -1; if (b.operator === 'equals' && a.operator !== 'equals') return 1; // Range conditions next const rangeOps = ['gt', 'gte', 'lt', 'lte', 'between']; const aIsRange = rangeOps.includes(a.operator); const bIsRange = rangeOps.includes(b.operator); if (aIsRange && !bIsRange) return -1; if (bIsRange && !aIsRange) return 1; // Keep original order for same types return 0; }); } /** * Compile to MongoDB with optimizations */ private static compileToMongoDB<T>(queryBuilder: IQueryBuilder<T>): CompiledQuery { const conditions = queryBuilder.getConditions(); const sorts = queryBuilder.getSorts(); const limit = queryBuilder.getLimit(); const offset = queryBuilder.getOffset(); const mongoQuery: any = {}; const mongoOptions: any = {}; // Build MongoDB query object with optimizations if (conditions.length > 0) { const optimizedConditions = this.optimizeMongoConditions(conditions); if (optimizedConditions.length === 1) { // Single condition - no need for $and Object.assign(mongoQuery, optimizedConditions[0]); } else { mongoQuery.$and = optimizedConditions; } } // Sort with index hints if (sorts.length > 0) { mongoOptions.sort = {}; sorts.forEach(sort => { mongoOptions.sort[sort.property] = sort.direction === 'asc' ? 1 : -1; }); // Add hint for compound indexes if (sorts.length > 1) { mongoOptions.hint = this.generateMongoIndexHint(sorts); } } // Limit and Skip if (limit !== undefined) { mongoOptions.limit = limit; } if (offset !== undefined) { mongoOptions.skip = offset; } return { mongodb: { query: mongoQuery, options: mongoOptions }, parameters: {} }; } /** * Optimize MongoDB conditions */ private static optimizeMongoConditions(conditions: QueryCondition[]): any[] { const conditionMap = new Map<string, any>(); // Group conditions by property to merge them for (const condition of conditions) { const compiled = this.compileConditionToMongoDB(condition); const property = condition.property; if (conditionMap.has(property)) { // Merge multiple conditions on same property const existing = conditionMap.get(property); if (typeof existing[property] === 'object' && compiled[property] && typeof compiled[property] === 'object') { // Merge MongoDB operators Object.assign(existing[property], compiled[property]); } else { // Create array for multiple values conditionMap.set(property, { $and: [existing, compiled] }); } } else { conditionMap.set(property, compiled); } } return Array.from(conditionMap.values()); } /** * Generate MongoDB index hint */ private static generateMongoIndexHint(sorts: any[]): any { const indexHint: any = {}; sorts.forEach(sort => { indexHint[sort.property] = sort.direction === 'asc' ? 1 : -1; }); return indexHint; } /** * Compile to Neo4j Cypher */ private static compileToNeo4j<T>(queryBuilder: IQueryBuilder<T>): CompiledQuery { const conditions = queryBuilder.getConditions(); const sorts = queryBuilder.getSorts(); const limit = queryBuilder.getLimit(); const offset = queryBuilder.getOffset(); let cypher = 'MATCH (n:Entity)'; const parameters: Record<string, any> = {}; let paramCount = 1; // WHERE clause if (conditions.length > 0) { const whereClause = conditions.map(condition => { const paramName = `param${paramCount++}`; parameters[paramName] = condition.value; return this.compileConditionToNeo4j(condition, paramName); }).join(' AND '); cypher += ` WHERE ${whereClause}`; } cypher += ' RETURN n'; // ORDER BY clause if (sorts.length > 0) { const orderClause = sorts.map(sort => `n.${sort.property} ${sort.direction.toUpperCase()}`).join(', '); cypher += ` ORDER BY ${orderClause}`; } // SKIP and LIMIT if (offset !== undefined) { cypher += ` SKIP ${offset}`; } if (limit !== undefined) { cypher += ` LIMIT ${limit}`; } return { neo4j: cypher, parameters }; } /** * Compile to Redis commands */ private static compileToRedis<T>(queryBuilder: IQueryBuilder<T>): CompiledQuery { const conditions = queryBuilder.getConditions(); const sorts = queryBuilder.getSorts(); const limit = queryBuilder.getLimit(); const offset = queryBuilder.getOffset(); // Redis is more limited - we'll use basic key patterns and scanning const commands: string[] = []; // Basic pattern matching if (conditions.length > 0) { const patterns = conditions.map(condition => { return this.compileConditionToRedis(condition); }); commands.push(`SCAN 0 MATCH ${patterns.join('*')}`); } else { commands.push('SCAN 0 MATCH *'); } return { redis: commands, parameters: {} }; } /** * Helper methods for SQL compilation */ private static compileConditionToSQL(condition: QueryCondition, paramName: string): string { const { property, operator } = condition; switch (operator) { case 'equals': case 'eq': return `${property} = $${paramName}`; case 'not_equals': case 'ne': case 'not': return `${property} != $${paramName}`; case 'greater_than': case 'gt': return `${property} > $${paramName}`; case 'greater_than_or_equal': case 'gte': return `${property} >= $${paramName}`; case 'less_than': case 'lt': return `${property} < $${paramName}`; case 'less_than_or_equal': case 'lte': return `${property} <= $${paramName}`; case 'in': return `${property} = ANY($${paramName})`; case 'not_in': return `${property} != ALL($${paramName})`; case 'like': return `${property} LIKE $${paramName}`; case 'ilike': return `${property} ILIKE $${paramName}`; case 'contains': return `${property} LIKE '%' || $${paramName} || '%'`; case 'starts_with': return `${property} LIKE $${paramName} || '%'`; case 'ends_with': return `${property} LIKE '%' || $${paramName}`; case 'is_null': return `${property} IS NULL`; case 'is_not_null': return `${property} IS NOT NULL`; case 'between': return `${property} BETWEEN $${paramName}.min AND $${paramName}.max`; case 'regex': return `${property} ~ $${paramName}`; default: throw new Error(`Unsupported SQL operator: ${operator}`); } } /** * Helper methods for MongoDB compilation */ private static compileConditionToMongoDB(condition: QueryCondition): any { const { property, operator, value } = condition; switch (operator) { case 'equals': case 'eq': return { [property]: value }; case 'not_equals': case 'ne': case 'not': return { [property]: { $ne: value } }; case 'greater_than': case 'gt': return { [property]: { $gt: value } }; case 'greater_than_or_equal': case 'gte': return { [property]: { $gte: value } }; case 'less_than': case 'lt': return { [property]: { $lt: value } }; case 'less_than_or_equal': case 'lte': return { [property]: { $lte: value } }; case 'in': return { [property]: { $in: value } }; case 'not_in': return { [property]: { $nin: value } }; case 'like': case 'contains': return { [property]: { $regex: new RegExp(value, 'i') } }; case 'starts_with': return { [property]: { $regex: new RegExp(`^${value}`, 'i') } }; case 'ends_with': return { [property]: { $regex: new RegExp(`${value}$`, 'i') } }; case 'is_null': return { [property]: null }; case 'is_not_null': return { [property]: { $ne: null } }; case 'between': return { [property]: { $gte: value.min, $lte: value.max } }; case 'regex': return { [property]: { $regex: value } }; default: throw new Error(`Unsupported MongoDB operator: ${operator}`); } } /** * Helper methods for Neo4j compilation */ private static compileConditionToNeo4j(condition: QueryCondition, paramName: string): string { const { property, operator } = condition; switch (operator) { case 'equals': case 'eq': return `n.${property} = $${paramName}`; case 'not_equals': case 'ne': case 'not': return `n.${property} <> $${paramName}`; case 'greater_than': case 'gt': return `n.${property} > $${paramName}`; case 'greater_than_or_equal': case 'gte': return `n.${property} >= $${paramName}`; case 'less_than': case 'lt': return `n.${property} < $${paramName}`; case 'less_than_or_equal': case 'lte': return `n.${property} <= $${paramName}`; case 'in': return `n.${property} IN $${paramName}`; case 'not_in': return `NOT n.${property} IN $${paramName}`; case 'contains': return `n.${property} CONTAINS $${paramName}`; case 'starts_with': return `n.${property} STARTS WITH $${paramName}`; case 'ends_with': return `n.${property} ENDS WITH $${paramName}`; case 'is_null': return `n.${property} IS NULL`; case 'is_not_null': return `n.${property} IS NOT NULL`; case 'regex': return `n.${property} =~ $${paramName}`; default: throw new Error(`Unsupported Neo4j operator: ${operator}`); } } /** * Helper methods for Redis compilation */ private static compileConditionToRedis(condition: QueryCondition): string { const { property, operator, value } = condition; // Redis has limited querying capabilities - we'll use basic pattern matching switch (operator) { case 'equals': case 'eq': return `${property}:${value}`; case 'contains': return `${property}:*${value}*`; case 'starts_with': return `${property}:${value}*`; case 'ends_with': return `${property}:*${value}`; default: // For complex queries, we'll need to fetch all and filter in memory return `${property}:*`; } } } /** * Query execution engine that uses the compiler */ export class QueryExecutor { static async execute<T>( queryBuilder: IQueryBuilder<T>, adapter: QueryDatabaseAdapter, entityClass: new (...args: any[]) => T ): Promise<any> { const compiled = QueryCompiler.compile(queryBuilder, adapter.type); // Execute async conditions first const asyncConditions = queryBuilder.getAsyncConditions(); const asyncResults = await Promise.all( asyncConditions.map(condition => this.executeAsyncCondition(condition, adapter)) ); // Execute the main query const result = await adapter.execute(compiled, entityClass); // Filter results by async conditions if (asyncResults.length > 0) { const filteredData = []; for (const item of result.data) { const asyncChecks = await Promise.all( asyncResults.map(condition => condition(item)) ); if (asyncChecks.every(check => check)) { filteredData.push(item); } } result.data = filteredData; } return result; } private static async executeAsyncCondition<T>( condition: (entity: T) => Promise<boolean>, adapter: QueryDatabaseAdapter ): Promise<(entity: T) => Promise<boolean>> { // This is a simplified implementation // In practice, you'd want to optimize this by pushing conditions to the database where possible return condition; } } /** * Query execution database adapter interface */ export interface QueryDatabaseAdapter { type: 'postgresql' | 'mongodb' | 'neo4j' | 'redis'; execute<T>(compiled: CompiledQuery, entityClass: new (...args: any[]) => T): Promise<any>; }