UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

555 lines 22.7 kB
/** * Field Disambiguator - Phase 4C Implementation * * Handles ambiguous field names across tables by: * 1. Detecting ambiguous field references * 2. Applying context-based resolution rules * 3. Supporting explicit table prefixes * 4. Managing field aliases * * Key features: * - Automatic disambiguation based on query context * - Support for table.field notation * - Field aliasing with AS clause * - Smart error messages for truly ambiguous cases */ import { getLogger } from '../../logging/Logger.js'; import { JSONPathHandler } from './JSONPathHandler.js'; const logger = getLogger(); export class FieldDisambiguator { fieldCatalog; jsonHandler; // Common field names that often cause ambiguity COMMON_AMBIGUOUS_FIELDS = new Set([ 'id', 'name', 'key', 'description', 'created_time', 'updated_time', 'archived', 'project_id', 'created', 'modified' ]); constructor(fieldCatalog) { this.fieldCatalog = fieldCatalog; this.jsonHandler = new JSONPathHandler(); logger.info('FieldDisambiguator initialized'); } /** * Disambiguate all fields in a query */ disambiguateQuery(query) { const context = this.buildContext(query); const result = { fields: new Map(), errors: [], warnings: [] }; // Process SELECT fields if (query.select) { for (const field of query.select) { const disambiguated = this.disambiguateField(field, context); result.fields.set(field, disambiguated); if (disambiguated.isAmbiguous && disambiguated.possibleMatches) { result.warnings.push(`Field '${field}' is ambiguous. Possible matches: ${disambiguated.possibleMatches.join(', ')}`); } } } // Process WHERE conditions if (query.where) { for (const condition of query.where) { const field = condition.field; if (!result.fields.has(field)) { const disambiguated = this.disambiguateField(field, context); result.fields.set(field, disambiguated); } } } // Process GROUP BY fields if (query.groupBy) { for (const field of query.groupBy) { if (!result.fields.has(field)) { const disambiguated = this.disambiguateField(field, context); result.fields.set(field, disambiguated); } } } // Process ORDER BY fields if (query.orderBy) { for (const orderSpec of query.orderBy) { const field = orderSpec.field; if (!result.fields.has(field)) { const disambiguated = this.disambiguateField(field, context); result.fields.set(field, disambiguated); } } } // Validate disambiguation results this.validateDisambiguation(result, context); return result; } /** * Build disambiguation context from query */ buildContext(query) { const context = { primaryEntity: query.find, joinedEntities: [], fieldAliases: new Map() }; // FIRST: Extract joined entities from explicit JOIN clauses if (query.joins && query.joins.length > 0) { for (const join of query.joins) { if (join.entity && !context.joinedEntities.includes(join.entity)) { context.joinedEntities.push(join.entity); } } } // SECOND: Extract joined entities from field references const allFields = [ ...(query.select || []), ...(query.where?.map(w => w.field) || []), ...(query.groupBy || []), ...(query.orderBy?.map(o => o.field) || []) ]; for (const field of allFields) { // Skip JSON paths and function calls when building context if (this.isComputedField(field) || this.jsonHandler.isJSONPath(field)) { continue; } // Check for table prefix if (field.includes('.')) { const [table] = field.split('.'); if (table !== context.primaryEntity && !context.joinedEntities.includes(table)) { context.joinedEntities.push(table); } } // Check for alias const aliasMatch = field.match(/(.+)\s+as\s+(.+)/i); if (aliasMatch) { const [, originalField, alias] = aliasMatch; context.fieldAliases.set(alias.trim(), originalField.trim()); } } // L7-13 FIX: Extract aggregation aliases from query // This handles cases where ORDER BY references aliases created by aggregations if (query.aggregations && query.aggregations.length > 0) { for (const agg of query.aggregations) { if (agg.alias) { // Map alias to the aggregation function (e.g., frequency_count -> COUNT(*)) const aggFunction = `${agg.function.toUpperCase()}(${agg.field || '*'})`; context.fieldAliases.set(agg.alias, aggFunction); logger.debug(`Added aggregation alias mapping: ${agg.alias} -> ${aggFunction}`); } } } return context; } /** * Disambiguate a single field */ disambiguateField(field, context) { // Handle field aliases const aliasMatch = field.match(/^(.+?)\s+as\s+(.+)$/i); if (aliasMatch) { const [, originalField, alias] = aliasMatch; const disambiguated = this.disambiguateField(originalField.trim(), context); disambiguated.alias = alias.trim(); return disambiguated; } // L7-13 FIX: Handle aggregation aliases (e.g., frequency_count) if (context.fieldAliases.has(field)) { const resolvedField = context.fieldAliases.get(field); logger.debug(`Resolved alias "${field}" to "${resolvedField}"`); return { originalField: field, resolvedField: resolvedField, tableName: context.primaryEntity, fieldName: field, alias: field, isAmbiguous: false, isJsonPath: false }; } // Handle all computed fields (aggregate functions, etc.) if (this.isComputedField(field)) { return { originalField: field, resolvedField: field, tableName: context.primaryEntity, fieldName: field, isAmbiguous: false, isJsonPath: field.toUpperCase().includes('JSON_') }; } // Check if this is a JSON path if (this.jsonHandler.isJSONPath(field) && (field.includes('$') || field.split('.').length > 2)) { // JSON paths don't need table disambiguation return { originalField: field, resolvedField: field, tableName: context.primaryEntity, fieldName: field, isAmbiguous: false, isJsonPath: true }; } // Handle explicit table prefix if (field.includes('.')) { const parts = field.split('.'); // Check if this is a JSON path with entity prefix (e.g., flag.environments.production) if (parts.length > 2 && this.jsonHandler.isJSONPath(parts.slice(1).join('.'))) { return { originalField: field, resolvedField: field, tableName: parts[0], fieldName: field, isAmbiguous: false, isJsonPath: true }; } // Regular table.field reference const [table, fieldName] = field.split('.'); return { originalField: field, resolvedField: field, tableName: table, fieldName: fieldName, isAmbiguous: false }; } // CRITICAL FIX: Handle known join table fields that aren't in the field catalog if (context.joinedEntities.includes('flag_environments')) { // Known flag_environments fields const flagEnvFields = ['environment_key', 'enabled', 'rules_summary']; if (flagEnvFields.includes(field)) { return { originalField: field, resolvedField: `flag_environments.${field}`, tableName: 'flag_environments', fieldName: field, isAmbiguous: false }; } } // Check all entities (primary + joined) for potential matches const possibleMatches = []; // Check primary entity const primaryEntityField = `${context.primaryEntity}.${field}`; if (this.fieldExists(primaryEntityField)) { possibleMatches.push(primaryEntityField); } // Check joined entities for (const entity of context.joinedEntities) { const entityField = `${entity}.${field}`; if (this.fieldExists(entityField)) { possibleMatches.push(entityField); } } // Determine if we need prefixing const hasJoins = context.joinedEntities.length > 0; const isAmbiguous = possibleMatches.length > 1; // CRITICAL FIX: If we have JOINs, ALL fields should be qualified to avoid ambiguity if (hasJoins && possibleMatches.length === 0) { // Assume field exists in primary entity and qualify it possibleMatches.push(primaryEntityField); } // If only one match found if (possibleMatches.length === 1) { const [table, fieldName] = possibleMatches[0].split('.'); // Always use prefix when there are joins to avoid ambiguity const resolvedField = hasJoins ? possibleMatches[0] : field; return { originalField: field, resolvedField: resolvedField, tableName: table, fieldName: fieldName, isAmbiguous: false }; } // Multiple matches - field is ambiguous if (possibleMatches.length > 1) { // For common ambiguous fields, prefer primary entity if available if (this.COMMON_AMBIGUOUS_FIELDS.has(field)) { const primaryMatch = possibleMatches.find(m => m.startsWith(`${context.primaryEntity}.`)); if (primaryMatch) { const [table, fieldName] = primaryMatch.split('.'); return { originalField: field, resolvedField: primaryMatch, tableName: table, fieldName: fieldName, isAmbiguous: true, possibleMatches }; } } // Return first match with ambiguity warning const [table, fieldName] = possibleMatches[0].split('.'); return { originalField: field, resolvedField: possibleMatches[0], tableName: table, fieldName: fieldName, isAmbiguous: true, possibleMatches }; } // No matches found - might be a computed field or error return { originalField: field, resolvedField: field, tableName: context.primaryEntity, fieldName: field, isAmbiguous: false }; } /** * Check if a field exists in the catalog */ fieldExists(fieldPath) { // First try the exact field path let fieldInfo = this.fieldCatalog.getFieldInfo(fieldPath); if (fieldInfo !== null) { return true; } // If that fails and it's a table.field reference, try entity.field format if (fieldPath.includes('.')) { const [tableName, fieldName] = fieldPath.split('.'); // Try converting table name to entity name using EntityTableMapper const entityName = this.getEntityNameFromTable(tableName); if (entityName) { const entityFieldPath = `${entityName}.${fieldName}`; fieldInfo = this.fieldCatalog.getFieldInfo(entityFieldPath); if (fieldInfo !== null) { return true; } } // Also try the reverse - maybe the field catalog uses table names const tableFieldPath = `${tableName}.${fieldName}`; fieldInfo = this.fieldCatalog.getFieldInfo(tableFieldPath); if (fieldInfo !== null) { return true; } } return false; } /** * Convert table name to entity name (reverse of EntityTableMapper) */ getEntityNameFromTable(tableName) { // Common table-to-entity mappings const tableToEntityMap = { 'flags': 'flag', 'flag_environments': 'flag_environment', 'experiments': 'experiment', 'variations': 'variation', 'audiences': 'audience', 'events': 'event', 'attributes': 'attribute', 'campaigns': 'campaign', 'pages': 'page', 'extensions': 'extension', 'groups': 'group', 'webhooks': 'webhook', 'projects': 'project' }; return tableToEntityMap[tableName] || null; } /** * Check if this is a known A/B test field that bypasses catalog validation */ isKnownABTestField(field) { // Extract field name if it has table prefix const fieldName = field.includes('.') ? field.split('.')[1] : field; // Known A/B test fields that might not be in the catalog const abTestFields = [ 'AB', 'ab', 'a_b', 'percentage_included', 'traffic_allocation', 'traffic', 'percentage' ]; return abTestFields.includes(fieldName); } /** * Check if this is a join table field that might not be in the field catalog */ isJoinTableField(fieldPath) { if (!fieldPath.includes('.')) { return false; } const [tableName, fieldName] = fieldPath.split('.'); // Known join tables that might not be in the field catalog const joinTables = [ 'flag_environments', 'experiment_variations', 'experiment_metrics', 'campaign_experiments', 'page_experiments', 'audience_segments', 'rules', // Added for A/B test rules with percentage_included 'experiments' // Added for traffic_allocation and other experiment fields ]; return joinTables.includes(tableName); } /** * Validate disambiguation results */ validateDisambiguation(result, context) { // Check for fields that couldn't be resolved for (const [field, disambiguated] of result.fields) { // Skip validation for simple fields without entity prefix in single-table queries if (context.joinedEntities.length === 0 && !disambiguated.resolvedField.includes('.')) { continue; // Simple field in single table query - no validation needed } // Check for unprefixed fields in multi-table queries that are known A/B test fields if (context.joinedEntities.length > 0 && !disambiguated.resolvedField.includes('.')) { if (this.isKnownABTestField(disambiguated.resolvedField)) { continue; // Skip validation for known A/B test fields } } // Skip validation for JSON paths if (disambiguated.isJsonPath) { continue; } // Check fields without prefix that don't exist in catalog if (!this.fieldExists(disambiguated.resolvedField) && !disambiguated.resolvedField.includes('.')) { // In multi-table queries, unprefixed fields need special handling if (context.joinedEntities.length > 0) { // L7-13 FIX: Check if this is an alias that resolved to a computed field const isAlias = context.fieldAliases.has(field); const isComputedOrAlias = isAlias || this.isComputedField(field) || this.isComputedField(disambiguated.resolvedField); if (!isComputedOrAlias && !this.isKnownABTestField(field)) { result.errors.push(`Field '${field}' could not be resolved to any table`); } } } if (!this.fieldExists(disambiguated.resolvedField) && disambiguated.resolvedField.includes('.')) { // Check if it's a computed field (COUNT(*), etc.) if (!this.isComputedField(field)) { // Check if it's a join table field that might not be in the catalog if (this.isJoinTableField(disambiguated.resolvedField)) { // Skip validation for join table fields - they'll be validated by SQL execution continue; } // Check if it's a known A/B test field if (this.isKnownABTestField(disambiguated.resolvedField)) { // Skip validation for known A/B test fields continue; } result.errors.push(`Field '${field}' could not be resolved to any table`); } } } // Check for inconsistent table references const referencedTables = new Set(); for (const [, disambiguated] of result.fields) { referencedTables.add(disambiguated.tableName); } // Ensure all referenced tables are either primary or joined for (const table of referencedTables) { if (table !== context.primaryEntity && !context.joinedEntities.includes(table)) { result.warnings.push(`Table '${table}' is referenced but not explicitly joined. This may cause query errors.`); } } } /** * Get plural form of entity name for SQL table names */ getPluralEntityName(entity) { // Simple pluralization rules const pluralMap = { 'flag': 'flags', 'event': 'events', 'audience': 'audiences', 'attribute': 'attributes', 'variation': 'variations', 'rule': 'rules', 'experiment': 'experiments', 'campaign': 'campaigns', 'page': 'pages', 'extension': 'extensions', 'webhook': 'webhooks', 'collaborator': 'collaborators', 'environment': 'environments', 'flag_environment': 'flag_environments', 'project': 'projects', 'group': 'groups', // Handle already-plural entities 'flags': 'flags', 'rules': 'rules', 'experiments': 'experiments', 'variations': 'variations', 'attributes': 'attributes', 'pages': 'pages', 'extensions': 'extensions', 'webhooks': 'webhooks', 'collaborators': 'collaborators', 'environments': 'environments', 'flag_environments': 'flag_environments', 'projects': 'projects', 'groups': 'groups' }; // Check if entity is already plural by checking if it ends with 's' // but not 'ss' (like 'class') or other special cases if (entity.endsWith('s') && !entity.endsWith('ss') && !entity.endsWith('us')) { return entity; // Already plural, return as-is } return pluralMap[entity] || entity + 's'; } /** * Check if a field is a computed field */ isComputedField(field) { // Check for aggregate functions const aggregatePattern = /^(COUNT|SUM|AVG|MAX|MIN)\s*\(/i; if (aggregatePattern.test(field)) { return true; } // Check for * wildcard if (field === '*') { return true; } // Check for expressions if (field.includes('(') || field.includes(')')) { return true; } return false; } /** * Apply disambiguation to a query */ applyDisambiguation(query, result) { const disambiguatedQuery = { ...query }; // Apply to SELECT fields if (disambiguatedQuery.select) { disambiguatedQuery.select = disambiguatedQuery.select.map(field => { const disambiguated = result.fields.get(field); if (disambiguated) { if (disambiguated.alias) { return `${disambiguated.resolvedField} as ${disambiguated.alias}`; } return disambiguated.resolvedField; } return field; }); } // Apply to WHERE conditions if (disambiguatedQuery.where) { disambiguatedQuery.where = disambiguatedQuery.where.map(condition => ({ ...condition, field: result.fields.get(condition.field)?.resolvedField || condition.field })); } // Apply to GROUP BY if (disambiguatedQuery.groupBy) { disambiguatedQuery.groupBy = disambiguatedQuery.groupBy.map(field => result.fields.get(field)?.resolvedField || field); } // Apply to ORDER BY if (disambiguatedQuery.orderBy) { disambiguatedQuery.orderBy = disambiguatedQuery.orderBy.map(orderSpec => ({ ...orderSpec, field: result.fields.get(orderSpec.field)?.resolvedField || orderSpec.field })); } return disambiguatedQuery; } } //# sourceMappingURL=FieldDisambiguator.js.map