@wearesage/schema
Version:
A flexible schema definition and validation system for TypeScript with multi-database support
637 lines (560 loc) • 19.6 kB
text/typescript
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>;
}