UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

541 lines 21.9 kB
/** * Multi-Table SQL Builder - Phase 4A Implementation * * Extends SQLBuilder to handle complex multi-entity joins using JoinPathPlanner * Generates optimized SQL for queries across multiple entities. */ import { getLogger } from '../../logging/Logger.js'; import { SQLBuilder } from './SQLBuilder.js'; import { JoinPathPlanner } from './JoinPathPlanner.js'; const logger = getLogger(); export class MultiTableSQLBuilder extends SQLBuilder { joinPathPlanner; activeJoinPaths = new Map(); fieldMappings = new Map(); constructor(fieldCatalog) { super(fieldCatalog); this.joinPathPlanner = new JoinPathPlanner(); } /** * Get access to the field catalog */ getFieldCatalog() { return this.fieldCatalog; } /** * Build SQL for multi-entity queries */ async buildMultiEntitySQL(query) { logger.info(`Building multi-entity SQL for primary entity: ${query.find}`); // Reset state this.activeJoinPaths.clear(); this.fieldMappings.clear(); // Disambiguate fields first const disambiguationResult = this.fieldDisambiguator.disambiguateQuery(query); if (disambiguationResult.errors.length > 0) { throw new Error(`Field disambiguation errors: ${disambiguationResult.errors.join(', ')}`); } // Apply disambiguation to query const disambiguatedQuery = this.fieldDisambiguator.applyDisambiguation(query, disambiguationResult); // Log warnings if any if (disambiguationResult.warnings.length > 0) { logger.warn(`Field disambiguation warnings: ${disambiguationResult.warnings.join(', ')}`); } // Discover required entities from query const requiredEntities = await this.discoverRequiredEntities(disambiguatedQuery); logger.info(`Discovered required entities: ${requiredEntities.join(', ')}`); // Plan join paths if not provided let joinPaths = disambiguatedQuery.joinPaths; if (!joinPaths && requiredEntities.length > 1) { joinPaths = await this.joinPathPlanner.findOptimalJoinPath(requiredEntities); logger.info(`Planned ${joinPaths.length} join paths`); } // Store join paths for use in field resolution if (joinPaths) { for (const path of joinPaths) { const key = `${path.from.entity}-${path.to.entity}`; if (!this.activeJoinPaths.has(key)) { this.activeJoinPaths.set(key, []); } this.activeJoinPaths.get(key).push(path); } } // Pre-resolve all fields across entities await this.resolveFieldsAcrossEntities(disambiguatedQuery); // Build SQL components const selectClause = await this.buildMultiEntitySelectClause(disambiguatedQuery); const fromClause = this.getTableName(disambiguatedQuery.find); const joinClause = this.buildComplexJoinClause(joinPaths || []); const whereClause = await this.buildMultiEntityWhereClause(disambiguatedQuery); const groupByClause = await this.buildMultiEntityGroupByClause(disambiguatedQuery); const orderByClause = await this.buildMultiEntityOrderByClause(disambiguatedQuery); const havingClause = this.buildMultiEntityHavingClause(disambiguatedQuery); // Assemble final SQL let sql = `SELECT ${selectClause}\nFROM ${fromClause}`; if (joinClause) { sql += `\n${joinClause}`; } if (whereClause) { sql += `\nWHERE ${whereClause}`; } if (groupByClause) { sql += `\nGROUP BY ${groupByClause}`; } if (havingClause) { sql += `\nHAVING ${havingClause}`; } if (orderByClause) { sql += `\nORDER BY ${orderByClause}`; } if (query.limit) { sql += `\nLIMIT ${query.limit}`; } if (query.offset) { sql += `\nOFFSET ${query.offset}`; } logger.info(`Generated multi-entity SQL: ${sql}`); return sql; } /** * Discover all entities required for the query */ async discoverRequiredEntities(query) { const entities = new Set(); entities.add(query.find); // Primary entity // Add explicitly requested entities if (query.entities) { query.entities.forEach(e => entities.add(e)); } // Discover entities from field prefixes in SELECT if (query.select) { for (const field of query.select) { const entityFromField = this.extractEntityFromField(field); if (entityFromField && entityFromField !== query.find) { entities.add(entityFromField); } } } // Discover entities from field prefixes in WHERE if (query.where) { for (const condition of query.where) { const entityFromField = this.extractEntityFromField(condition.field); if (entityFromField && entityFromField !== query.find) { entities.add(entityFromField); } } } // Discover entities from field prefixes in GROUP BY if (query.groupBy) { for (const field of query.groupBy) { const entityFromField = this.extractEntityFromField(field); if (entityFromField && entityFromField !== query.find) { entities.add(entityFromField); } } } // Discover entities from field prefixes in ORDER BY if (query.orderBy) { for (const order of query.orderBy) { const entityFromField = this.extractEntityFromField(order.field); if (entityFromField && entityFromField !== query.find) { entities.add(entityFromField); } } } return Array.from(entities); } /** * Extract entity name from qualified field (e.g., "experiments.name" -> "experiments") */ extractEntityFromField(field) { const parts = field.split('.'); if (parts.length >= 2) { const entity = parts[0]; // Validate it's a known entity const knownEntities = ['experiments', 'pages', 'events', 'audiences', 'flags', 'variations', 'rules']; if (knownEntities.includes(entity)) { return entity; } } return null; } /** * Resolve all fields across multiple entities */ async resolveFieldsAcrossEntities(query) { const mappings = []; const allFields = new Set(); // Collect all fields from query if (query.select) { query.select.forEach(f => allFields.add(f)); } if (query.where) { query.where.forEach(w => allFields.add(w.field)); } if (query.groupBy) { query.groupBy.forEach(f => allFields.add(f)); } if (query.orderBy) { query.orderBy.forEach(o => allFields.add(o.field)); } for (const field of allFields) { try { const mapping = await this.resolveFieldWithEntity(field, query); mappings.push(mapping); this.fieldMappings.set(field, mapping); } catch (error) { logger.warn(`Could not resolve field ${field}: ${error instanceof Error ? error.message : String(error)}`); // Create fallback mapping const mapping = { originalField: field, resolvedField: field, entity: query.find, table: this.getTableName(query.find), requiresJoin: false }; mappings.push(mapping); this.fieldMappings.set(field, mapping); } } return mappings; } /** * Resolve field with entity context */ async resolveFieldWithEntity(field, query) { // Handle aggregation functions and already-resolved SQL expressions if (field.includes('(') && field.includes(')')) { return { originalField: field, resolvedField: field, entity: query.find, table: this.getTableName(query.find), requiresJoin: false }; } // Handle aliased fields const [fieldName] = field.split(' as ').map(s => s.trim()); // Check if field is qualified with entity name (e.g., "experiments.name") const parts = fieldName.split('.'); if (parts.length >= 2) { const entityName = parts[0]; const actualField = parts.slice(1).join('.'); try { const location = await this.getFieldCatalog().resolveField(entityName, actualField); const requiresJoin = entityName !== query.find; return { originalField: field, resolvedField: `${this.getTableName(entityName)}.${actualField}`, entity: entityName, table: this.getTableName(entityName), requiresJoin }; } catch (error) { logger.debug(`Could not resolve qualified field ${entityName}.${actualField}`); } } // Try to resolve with primary entity first try { const location = await this.getFieldCatalog().resolveField(query.find, fieldName); return { originalField: field, resolvedField: this.mapFieldLocationToSQL(location, query.find), entity: query.find, table: this.getTableName(query.find), requiresJoin: false }; } catch (error) { // Try other entities if we have join paths for (const [key, paths] of this.activeJoinPaths) { const [, toEntity] = key.split('-'); try { const location = await this.getFieldCatalog().resolveField(toEntity, fieldName); return { originalField: field, resolvedField: this.mapFieldLocationToSQL(location, toEntity), entity: toEntity, table: this.getTableName(toEntity), requiresJoin: true, joinPath: paths }; } catch (error) { // Continue trying other entities } } } throw new Error(`Could not resolve field ${fieldName} in any available entity`); } /** * Build complex JOIN clause for multiple entities */ buildComplexJoinClause(joinPaths) { if (joinPaths.length === 0) { return ''; } const joins = []; const processedJoins = new Set(); for (const path of joinPaths) { const joinKey = `${path.from.table}-${path.to.table}`; if (processedJoins.has(joinKey)) { continue; // Skip duplicate joins } let joinClause = ''; if (path.relationshipType === 'many-to-many' && path.joinTable) { // Handle many-to-many with junction table const junctionJoinKey = `${path.from.table}-${path.joinTable}`; const targetJoinKey = `${path.joinTable}-${path.to.table}`; if (!processedJoins.has(junctionJoinKey)) { joinClause += `${path.joinType} JOIN ${path.joinTable} ON ${path.from.table}.${path.from.field} = ${path.joinTable}.${path.from.field}\n`; processedJoins.add(junctionJoinKey); } if (!processedJoins.has(targetJoinKey)) { joinClause += `${path.joinType} JOIN ${path.to.table} ON ${path.joinTable}.${path.to.field} = ${path.to.table}.${path.to.field}`; processedJoins.add(targetJoinKey); } } else { // Handle direct joins (one-to-one, one-to-many) joinClause = `${path.joinType} JOIN ${path.to.table} ON ${path.from.table}.${path.from.field} = ${path.to.table}.${path.to.field}`; } if (joinClause) { joins.push(joinClause); processedJoins.add(joinKey); } } return joins.join('\n'); } /** * Build SELECT clause for multi-entity query */ async buildMultiEntitySelectClause(query) { const selectFields = query.select || ['*']; if (selectFields[0] === '*') { return '*'; } const mappedFields = []; for (const field of selectFields) { const mapping = this.fieldMappings.get(field); if (mapping) { // Handle aliased fields const [, alias] = field.split(' as ').map(s => s.trim()); if (alias) { mappedFields.push(`${mapping.resolvedField} as ${alias}`); } else { mappedFields.push(mapping.resolvedField); } } else { // Fallback to original field mappedFields.push(field); } } return mappedFields.join(', '); } /** * Build WHERE clause for multi-entity query */ async buildMultiEntityWhereClause(query) { if (!query.where || query.where.length === 0) { return ''; } const conditions = []; for (const condition of query.where) { const mapping = this.fieldMappings.get(condition.field); const sqlField = mapping ? mapping.resolvedField : condition.field; // Check if this is a date-related condition const isDateField = this.dateHandler.isDateField(condition.field); const isDateOperator = ['BETWEEN', 'YEAR', 'MONTH', 'DAY', 'LAST_N_DAYS'].includes(condition.operator) || (typeof condition.value === 'string' && this.dateHandler.getRelativeDateSQL(condition.value) !== null); let sqlCondition = ''; if (isDateField || isDateOperator) { // Handle date/time conditions const dateResult = this.dateHandler.parseDateFilter(condition); if (dateResult.isValid) { sqlCondition = dateResult.sqlExpression.replace(condition.field, sqlField); logger.debug(`Generated date condition: ${sqlCondition}`); } else { logger.warn(`Date parsing failed: ${dateResult.error}`); // Fallback to standard processing sqlCondition = this.buildMultiEntityStandardCondition(sqlField, condition); } } else { // Handle standard conditions sqlCondition = this.buildMultiEntityStandardCondition(sqlField, condition); } conditions.push(sqlCondition); } return conditions.join(' AND '); } /** * Build standard (non-date) condition for multi-entity queries */ buildMultiEntityStandardCondition(sqlField, condition) { let value = condition.value; // Handle different value types and operators if (condition.operator === 'IN' && Array.isArray(value)) { // Handle IN operator with array values const quotedValues = value.map(v => `'${String(v).replace(/'/g, "''")}'`); return `${sqlField} IN (${quotedValues.join(', ')})`; } else if (condition.operator === 'IN' && typeof value === 'string') { // Handle IN operator with comma-separated string const values = value.split(',').map(v => v.trim()); const quotedValues = values.map(v => `'${v.replace(/'/g, "''")}'`); return `${sqlField} IN (${quotedValues.join(', ')})`; } else if (condition.operator === 'BETWEEN' && Array.isArray(value) && value.length === 2) { // Handle BETWEEN operator return `${sqlField} BETWEEN '${value[0]}' AND '${value[1]}'`; } else if (typeof value === 'string') { value = `'${value.replace(/'/g, "''")}'`; return `${sqlField} ${condition.operator} ${value}`; } else if (value === null) { if (condition.operator === '=') { return `${sqlField} IS NULL`; } else if (condition.operator === '!=') { return `${sqlField} IS NOT NULL`; } else { return `${sqlField} ${condition.operator} NULL`; } } else if (Array.isArray(value)) { // Handle other array cases const quotedValues = value.map(v => `'${String(v).replace(/'/g, "''")}'`); return `${sqlField} ${condition.operator} (${quotedValues.join(', ')})`; } else { // Numbers, booleans, etc. return `${sqlField} ${condition.operator} ${value}`; } } /** * Build GROUP BY clause for multi-entity query */ async buildMultiEntityGroupByClause(query) { if (!query.groupBy || query.groupBy.length === 0) { return ''; } const groupFields = []; for (const field of query.groupBy) { // Check if the field is already a SQL expression (e.g., JSON_EXTRACT, function call, etc.) if (field.includes('(') && field.includes(')')) { // It's already a SQL expression, use it as-is groupFields.push(field); } else { // Try to resolve it through field mappings const mapping = this.fieldMappings.get(field); const sqlField = mapping ? mapping.resolvedField : field; groupFields.push(sqlField); } } return groupFields.join(', '); } /** * Build ORDER BY clause for multi-entity query */ async buildMultiEntityOrderByClause(query) { if (!query.orderBy || query.orderBy.length === 0) { return ''; } const orderClauses = []; for (const order of query.orderBy) { const mapping = this.fieldMappings.get(order.field); const sqlField = mapping ? mapping.resolvedField : order.field; orderClauses.push(`${sqlField} ${order.direction}`); } return orderClauses.join(', '); } /** * Build HAVING clause for multi-entity query */ buildMultiEntityHavingClause(query) { if (!query.having || query.having.length === 0) { return ''; } const conditions = []; for (const condition of query.having) { const mapping = this.fieldMappings.get(condition.field); const sqlField = mapping ? mapping.resolvedField : condition.field; conditions.push(`${sqlField} ${condition.operator} ${condition.value}`); } return conditions.join(' AND '); } /** * Map field location to SQL expression */ mapFieldLocationToSQL(location, entity) { const tableName = this.getTableName(entity); switch (location.physicalLocation.type) { case 'column': return `${tableName}.${location.physicalLocation.path}`; case 'json_path': return `JSON_EXTRACT(${tableName}.data_json, '${location.physicalLocation.jsonPath}')`; case 'related': // This should be handled by join planning return `${tableName}.${location.physicalLocation.path}`; case 'computed': return location.physicalLocation.path; // Computed fields are already SQL expressions default: return `${tableName}.${location.physicalLocation.path}`; } } /** * Get table name for entity */ getTableName(entity) { const tableMap = { 'flag': 'flags', 'flags': 'flags', 'experiment': 'experiments', 'experiments': 'experiments', 'page': 'pages', 'pages': 'pages', 'event': 'events', 'events': 'events', 'audience': 'audiences', 'audiences': 'audiences', 'variation': 'variations', 'variations': 'variations', 'rule': 'rules', 'rules': 'rules', 'ruleset': 'rulesets', 'rulesets': 'rulesets' }; return tableMap[entity] || entity; } /** * Get join path statistics */ getJoinStatistics() { const entitiesInvolved = new Set(); let totalCost = 0; const joinTypes = {}; for (const paths of this.activeJoinPaths.values()) { for (const path of paths) { entitiesInvolved.add(path.from.entity); entitiesInvolved.add(path.to.entity); totalCost += path.cost; joinTypes[path.joinType] = (joinTypes[path.joinType] || 0) + 1; } } return { totalJoinPaths: this.activeJoinPaths.size, entitiesInvolved: Array.from(entitiesInvolved), totalCost, joinTypes }; } } //# sourceMappingURL=MultiTableSQLBuilder.js.map