@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
541 lines • 21.9 kB
JavaScript
/**
* 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