@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
591 lines • 25.1 kB
JavaScript
/**
* TemplateQueryTranslator - Converts structured query templates to SQL
*
* This replaces the micro-kernel approach with deterministic template-based queries.
* Leverages existing field correction and synonym matching systems.
*
* Created: January 8, 2025
* Status: 🚧 IMPLEMENTATION STARTED
*/
import { SmartFieldMapper } from '../parsers/SmartFieldMapper.js';
import { ComprehensiveAutoCorrector } from '../validation/ComprehensiveAutoCorrector.js';
import { PaginationConfigManager } from '../config/PaginationConfig.js';
import { getLogger } from '../logging/Logger.js';
import { safeIdToString } from '../utils/SafeIdConverter.js';
// Import field mappings
import { FIELD_MAPPINGS_REGISTRY, FIELD_MAPPINGS } from './COMPLETE_FIELD_MAPPINGS.js';
// Use the imported FIELD_MAPPINGS as our comprehensive field registry
// Cast through unknown to handle readonly array type issue
const COMPREHENSIVE_FIELD_MAPPINGS = FIELD_MAPPINGS;
const logger = getLogger();
export class TemplateQueryTranslator {
smartFieldMapper;
paginationConfig;
// No view name mapping needed - we use actual database view names directly
// Known field mappings per view (subset for common corrections)
FIELD_CORRECTIONS = {
flags_unified_view: {
'key': 'flag_key',
'id': 'flag_id',
'name': 'flag_name',
'description': 'flag_description',
'is_enabled': 'enabled',
'flagKey': 'flag_key',
'flagName': 'flag_name',
'isEnabled': 'enabled',
'project': 'project_id',
'environment': 'environment_key',
// These fields actually exist in the view - no mapping needed
'simple_status': 'simple_status', // This is an actual field in the view
'rule_type': 'rule_type', // This field exists in the view
'rollout_percentage': 'rollout_percentage' // This field exists in the view
},
experiments_unified_view: {
'id': 'experiment_id',
'name': 'experiment_name',
'experimentName': 'experiment_name',
'campaign': 'campaign_name',
'variations': 'variation_count',
'metrics': 'metric_count',
'project': 'project_id'
},
flag_variations_flat: {
'flag': 'flag_key',
'key': 'flag_key',
'variation': 'variation_key',
'variable': 'variable_name',
'value': 'variable_value',
'type': 'variable_type',
'percentage': 'percentage_included',
'project': 'project_id',
'environment': 'environment_key'
},
flag_variation_variables: {
'flag': 'flag_key',
'key': 'flag_key',
'variation': 'variation_key',
'variable': 'variable_key',
'value': 'variable_value',
'type': 'variable_type',
'project': 'project_id',
'environment': 'environment_key',
'rule': 'rule_key',
'variableKey': 'variable_key',
'variableName': 'variable_key',
'variable_name': 'variable_key'
},
flag_variables_summary: {
'flag': 'flag_key',
'key': 'flag_key',
'project': 'project_id',
'variables': 'variable_count',
'count': 'variable_count'
},
audiences_flat: {
'id': 'audience_id',
'name': 'audience_name',
'project': 'project_id'
},
pages_flat: {
'id': 'page_id',
'key': 'page_key',
'name': 'page_name',
'activation': 'activation_type',
'project': 'project_id'
}
};
constructor() {
this.smartFieldMapper = new SmartFieldMapper();
this.paginationConfig = new PaginationConfigManager();
logger.info('TemplateQueryTranslator initialized with auto-correction systems');
}
/**
* Main translation method
*/
async translateToSQL(template, db) {
logger.info('Translating structured query template', JSON.stringify(template));
// Validate template first
const validation = this.validateTemplate(template);
if (!validation.valid) {
throw new Error(`Invalid template: ${validation.errors?.join(', ')}`);
}
// 1. Resolve view name
const viewName = this.resolveViewName(template.from);
logger.debug(`Resolved view: ${template.from} -> ${viewName}`);
// 2. Build SELECT clause
const { selectClause, fieldMappings } = this.buildSelectClause(template.select, template.aggregate, viewName);
// 3. Build WHERE clause
const { whereClause, whereParams } = this.buildWhereClause(template.where || {}, viewName);
// 4. Build GROUP BY clause
const groupByClause = this.buildGroupByClause(template.group_by, viewName);
// 5. Build ORDER BY clause
const orderByClause = this.buildOrderByClause(template.order_by, viewName, template.aggregate);
// 6. Apply LIMIT and OFFSET for pagination
// Use pagination config as the primary source, with template as fallback
// This ensures our configured page size takes priority over client-provided values
const configuredPageSize = this.paginationConfig.getPageSize('analytics');
const templatePageSize = template.limit || template.page_size;
// Use the larger of configured vs template page size to prevent artificially small results
const limit = Math.max(configuredPageSize, templatePageSize || configuredPageSize);
const page = template.page || 1;
const offset = (page - 1) * limit;
// 7. Construct final SQL
let sql = `SELECT ${selectClause} FROM ${viewName}`;
if (whereClause) {
sql += ` WHERE ${whereClause}`;
}
if (groupByClause) {
sql += ` GROUP BY ${groupByClause}`;
}
if (orderByClause) {
sql += ` ORDER BY ${orderByClause}`;
}
// Add LIMIT for pagination (unless explicitly disabled)
if (!template.no_limit) {
sql += ` LIMIT ${limit}`;
if (offset > 0) {
sql += ` OFFSET ${offset}`;
}
}
logger.info(`Generated SQL: ${sql}`, JSON.stringify({ params: whereParams }));
// Log the complete query details for debugging aggregate queries
if (template.aggregate) {
logger.info('Aggregate query debug info', JSON.stringify({
selectClause,
groupByClause,
orderByClause,
aggregates: template.aggregate,
hasOrderBy: !!template.order_by,
orderByField: template.order_by?.field,
resolvedOrderBy: orderByClause
}));
}
// Generate COUNT query for total records
let countSql;
if (groupByClause) {
// For grouped queries, count the number of groups, not total rows
// Build the subquery without LIMIT/OFFSET
let subquery = `SELECT ${selectClause} FROM ${viewName}`;
if (whereClause) {
subquery += ` WHERE ${whereClause}`;
}
subquery += ` GROUP BY ${groupByClause}`;
countSql = `SELECT COUNT(*) as total_count FROM (${subquery}) as grouped_results`;
}
else {
// For non-grouped queries, count all matching rows
countSql = `SELECT COUNT(*) as total_count FROM ${viewName}`;
if (whereClause) {
countSql += ` WHERE ${whereClause}`;
}
}
return {
sql,
countSql, // Add count query
params: whereParams,
viewUsed: viewName,
fieldsResolved: fieldMappings,
limit,
offset,
page
};
}
/**
* Resolve view name - maps simplified names to actual database view names
*/
resolveViewName(input) {
// Map simplified view names to actual database view names
const viewMappings = {
'flags': 'flags_unified_view',
'experiments': 'experiments_unified_view',
'audiences': 'audiences_standalone_flat',
'audiences_flat': 'audiences_standalone_flat', // Add alias mapping
'pages': 'pages_standalone_flat',
'pages_flat': 'pages_standalone_flat', // Add alias mapping
'events': 'experiment_events_flat',
'flag_variations': 'flag_variations_flat',
'flag_variation_variables': 'flag_variation_variables',
'experiment_audiences': 'experiment_audiences_flat',
'experiment_pages': 'experiment_pages_flat',
'change_history': 'change_history_flat',
'usage': 'entity_usage_view',
'history': 'change_history_flat',
'flag_states': 'flag_state_history_view'
};
const mapped = viewMappings[input.toLowerCase()];
if (mapped) {
logger.debug(`View name mapped: ${input} -> ${mapped}`);
return mapped;
}
// Return as-is if already a full view name or no mapping found
return input;
}
/**
* Build SELECT clause with field resolution
*/
buildSelectClause(fields, aggregates, viewName) {
const fieldMappings = {};
const selectParts = [];
// Handle regular fields
if (!fields || fields.length === 0) {
selectParts.push('*');
}
else {
for (const field of fields) {
const resolved = this.resolveFieldName(field, viewName);
fieldMappings[field] = resolved;
selectParts.push(resolved);
}
}
// Handle aggregates
if (aggregates) {
for (const [func, field] of Object.entries(aggregates)) {
const resolvedField = field === '*' ? '*' : this.resolveFieldName(field, viewName);
const aggName = `${func}_${resolvedField}`.replace(/[^a-zA-Z0-9_]/g, '_');
selectParts.push(`${func.toUpperCase()}(${resolvedField}) as ${aggName}`);
}
}
return {
selectClause: selectParts.join(', '),
fieldMappings
};
}
/**
* Build WHERE clause with field resolution
*/
buildWhereClause(conditions, viewName) {
const whereParts = [];
const whereParams = [];
for (const [field, value] of Object.entries(conditions)) {
const resolvedField = this.resolveFieldName(field, viewName);
// Apply auto-correction to the value
const correctedValue = this.correctValue(value, resolvedField);
// Handle different value types
if (correctedValue === null) {
whereParts.push(`${resolvedField} IS NULL`);
}
else if (typeof correctedValue === 'object' && !Array.isArray(correctedValue)) {
// Handle comparison operators: { ">": 5 }, { "<": 10 }, etc.
const operators = Object.keys(correctedValue);
for (const op of operators) {
const opValue = correctedValue[op];
const sqlOperator = this.mapComparisonOperator(op);
whereParts.push(`${resolvedField} ${sqlOperator} ?`);
whereParams.push(opValue);
}
}
else if (Array.isArray(correctedValue)) {
const placeholders = correctedValue.map(() => '?').join(', ');
whereParts.push(`${resolvedField} IN (${placeholders})`);
whereParams.push(...correctedValue);
}
else if (typeof correctedValue === 'string' && correctedValue.startsWith('contains:')) {
// Special handling for contains search
const searchValue = correctedValue.substring('contains:'.length);
// Use case-insensitive search for key/name fields
if (resolvedField.includes('key') || resolvedField.includes('name')) {
whereParts.push(`LOWER(${resolvedField}) LIKE LOWER(?)`);
}
else {
whereParts.push(`${resolvedField} LIKE ?`);
}
whereParams.push(`%${searchValue}%`);
}
else if (typeof correctedValue === 'string' &&
(resolvedField.includes('key') || resolvedField.includes('name') ||
resolvedField === 'variable_key' || resolvedField === 'variation_key')) {
// Case-insensitive comparison for key/name fields
whereParts.push(`LOWER(${resolvedField}) = LOWER(?)`);
whereParams.push(correctedValue);
}
else {
whereParts.push(`${resolvedField} = ?`);
whereParams.push(correctedValue);
}
}
return {
whereClause: whereParts.join(' AND '),
whereParams
};
}
/**
* Resolve field name using comprehensive field mapping system
*/
resolveFieldName(field, viewName) {
// 1. Try direct field mapping from comprehensive system
const directMapping = COMPREHENSIVE_FIELD_MAPPINGS[field];
if (directMapping) {
// Check if this field exists in the target view
if (!viewName || directMapping.views.includes(viewName)) {
logger.debug(`Direct mapping: ${field} -> ${directMapping.viewColumn} (found in ${directMapping.views})`);
return directMapping.viewColumn;
}
}
// 2. Try view-specific corrections from local mappings
if (viewName && this.FIELD_CORRECTIONS[viewName]) {
const corrected = this.FIELD_CORRECTIONS[viewName][field];
if (corrected) {
logger.debug(`Local view correction: ${field} -> ${corrected} (view: ${viewName})`);
return corrected;
}
}
// 3. Try fuzzy matching against comprehensive field mappings
const allViewFields = Object.entries(COMPREHENSIVE_FIELD_MAPPINGS)
.filter(([_, mapping]) => !viewName || mapping.views.includes(viewName))
.map(([fieldName, _]) => fieldName);
// Try exact case-insensitive match
const caseInsensitiveMatch = allViewFields.find(f => f.toLowerCase() === field.toLowerCase());
if (caseInsensitiveMatch) {
const mapping = COMPREHENSIVE_FIELD_MAPPINGS[caseInsensitiveMatch];
logger.debug(`Case-insensitive match: ${field} -> ${mapping.viewColumn}`);
return mapping.viewColumn;
}
// 4. Try camelCase to snake_case conversion and match
const snakeCaseField = this.toSnakeCase(field);
const snakeCaseMapping = COMPREHENSIVE_FIELD_MAPPINGS[snakeCaseField];
if (snakeCaseMapping && (!viewName || snakeCaseMapping.views.includes(viewName))) {
logger.debug(`Snake case conversion: ${field} -> ${snakeCaseField} -> ${snakeCaseMapping.viewColumn}`);
return snakeCaseMapping.viewColumn;
}
// 5. Last resort: try basic case variations
const variations = [
field.toLowerCase(),
this.toSnakeCase(field),
field.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '')
];
for (const variant of variations) {
if (COMPREHENSIVE_FIELD_MAPPINGS[variant]) {
const mapping = COMPREHENSIVE_FIELD_MAPPINGS[variant];
if (!viewName || mapping.views.includes(viewName)) {
logger.debug(`Variant match: ${field} -> ${variant} -> ${mapping.viewColumn}`);
return mapping.viewColumn;
}
}
}
// Return original if no correction found
logger.warn(`Could not resolve field: ${field} in view: ${viewName}, using as-is. Available fields: ${allViewFields.slice(0, 5).join(', ')}...`);
return field;
}
/**
* Build GROUP BY clause
*/
buildGroupByClause(fields, viewName) {
if (!fields || fields.length === 0) {
return '';
}
const resolvedFields = fields.map(field => this.resolveFieldName(field, viewName));
return resolvedFields.join(', ');
}
/**
* Build ORDER BY clause
*/
buildOrderByClause(orderBy, viewName, aggregates) {
if (!orderBy) {
return '';
}
let resolvedField;
// Check if ordering by an aggregate function
if (aggregates && orderBy.field in aggregates) {
// Build the same alias that buildSelectClause creates
const field = aggregates[orderBy.field];
const aggField = field === '*' ? '*' : this.resolveFieldName(field, viewName);
resolvedField = `${orderBy.field}_${aggField}`.replace(/[^a-zA-Z0-9_]/g, '_');
}
else {
// Regular field
resolvedField = this.resolveFieldName(orderBy.field, viewName);
}
const direction = (orderBy.direction || 'asc').toUpperCase();
return `${resolvedField} ${direction}`;
}
/**
* Get entity type from view name for SmartFieldMapper
*/
getEntityTypeFromView(viewName) {
if (!viewName)
return 'flag'; // default
if (viewName.includes('flag'))
return 'flag';
if (viewName.includes('experiment'))
return 'experiment';
if (viewName.includes('audience'))
return 'audience';
if (viewName.includes('page'))
return 'page';
if (viewName.includes('event'))
return 'event';
return 'flag'; // default fallback
}
/**
* Correct values for SQLite compatibility
*/
correctValue(value, fieldName) {
// Special handling for comparison operators - preserve the object structure
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
// Check if this looks like a comparison operator object
const keys = Object.keys(value);
const comparisonOps = ['>', '<', '>=', '<=', '!=', '<>', 'eq', 'ne', 'gt', 'lt', 'gte', 'lte'];
if (keys.length === 1 && comparisonOps.includes(keys[0].toLowerCase())) {
logger.debug(`Preserving comparison operator for field ${fieldName}: ${JSON.stringify(value)}`);
return value;
}
}
// Special handling for contains: operator - preserve it as-is
if (typeof value === 'string' && value.startsWith('contains:')) {
logger.debug(`Preserving contains: operator for field ${fieldName}: ${value}`);
return value;
}
// Special handling for project_id - MUST be string
if (fieldName === 'project_id') {
const stringId = safeIdToString(value);
if (stringId !== value) {
logger.debug(`Converting project_id to safe string: ${value} -> "${stringId}"`);
}
return stringId;
}
// Handle arrays by correcting each element individually
if (Array.isArray(value)) {
logger.debug(`Processing array value for field ${fieldName}`);
return value.map(item => {
// Apply basic corrections to each array element
if (typeof item === 'boolean') {
return item ? 1 : 0;
}
if (typeof item === 'string') {
const lowerItem = item.toLowerCase();
if (lowerItem === 'true')
return 1;
if (lowerItem === 'false')
return 0;
}
// Don't run auto-corrector on array elements - it's designed for full entities
return item;
});
}
// Handle boolean to numeric conversion for SQLite
if (typeof value === 'boolean') {
logger.debug(`Converting boolean ${value} to ${value ? 1 : 0} for field ${fieldName}`);
return value ? 1 : 0;
}
// Handle string boolean representations
if (typeof value === 'string') {
const lowerValue = value.toLowerCase();
if (lowerValue === 'true') {
logger.debug(`Converting string 'true' to 1 for field ${fieldName}`);
return 1;
}
if (lowerValue === 'false') {
logger.debug(`Converting string 'false' to 0 for field ${fieldName}`);
return 0;
}
}
// Use ComprehensiveAutoCorrector for advanced corrections - but NOT for arrays
try {
// Use the static autoCorrect method with correct signature
const correctionResult = ComprehensiveAutoCorrector.autoCorrect('flag', { [fieldName]: value });
if (correctionResult.wasCorrected && correctionResult.correctedData[fieldName] !== value) {
logger.debug(`AutoCorrector: ${fieldName} ${value} -> ${correctionResult.correctedData[fieldName]}`);
return correctionResult.correctedData[fieldName];
}
}
catch (e) {
// If auto-correction fails, use original value
logger.debug(`AutoCorrector failed for ${fieldName}:`, e.message);
}
// Return original value if no correction needed
return value;
}
/**
* Validate template structure
*/
validateTemplate(template) {
const errors = [];
const warnings = [];
// Required fields
if (!template.from) {
errors.push('Missing required field: from');
}
// Type checks
if (template.select && !Array.isArray(template.select)) {
errors.push('Field "select" must be an array');
}
if (template.where && typeof template.where !== 'object') {
errors.push('Field "where" must be an object');
}
if (template.group_by && !Array.isArray(template.group_by)) {
errors.push('Field "group_by" must be an array');
}
// Warnings
if (template.aggregate && !template.group_by) {
warnings.push('Using aggregate functions without GROUP BY may produce unexpected results');
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
/**
* Get available fields for a view
*/
async getAvailableFields(viewName) {
// First resolve the view name to handle aliases
const resolvedViewName = this.resolveViewName(viewName);
// Get fields from the imported FIELD_MAPPINGS_REGISTRY
const fieldsForView = [];
// Iterate through all field mappings and collect fields that belong to this view
for (const [fieldName, mapping] of Object.entries(FIELD_MAPPINGS_REGISTRY)) {
if (mapping.views.includes(resolvedViewName)) {
// Use the viewColumn name which is the actual database column
fieldsForView.push(mapping.viewColumn);
}
}
// Debug logging
logger.debug(`getAvailableFields called with viewName: "${viewName}" -> resolved to: "${resolvedViewName}"`);
logger.debug(`Found ${fieldsForView.length} fields for view "${resolvedViewName}"`);
// Return the fields or ['*'] as fallback if no fields found
return fieldsForView.length > 0 ? fieldsForView : ['*'];
}
/**
* Map comparison operators to SQL operators
*/
mapComparisonOperator(op) {
const operatorMap = {
'>': '>',
'<': '<',
'>=': '>=',
'<=': '<=',
'!=': '!=',
'<>': '<>',
'eq': '=',
'ne': '!=',
'gt': '>',
'lt': '<',
'gte': '>=',
'lte': '<=',
'in': 'IN',
'not_in': 'NOT IN',
'like': 'LIKE',
'not_like': 'NOT LIKE',
'is_null': 'IS NULL',
'is_not_null': 'IS NOT NULL'
};
const sqlOp = operatorMap[op.toLowerCase()];
if (!sqlOp) {
throw new Error(`Unknown comparison operator: ${op}`);
}
return sqlOp;
}
/**
* Convert camelCase to snake_case
*/
toSnakeCase(str) {
return str
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
}
}
// Export for use in OptimizelyMCPTools
export default TemplateQueryTranslator;
//# sourceMappingURL=TemplateQueryTranslator.js.map