UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

591 lines 25.1 kB
/** * 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