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