import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete"; import { type TableSchema, type ColumnSchema, TSQL_CLICKHOUSE_FUNCTIONS, TSQL_AGGREGATIONS, } from "@internal/tsql"; /** * SQL keywords for autocomplete */ const SQL_KEYWORDS = [ "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "IS", "NULL", "TRUE", "FALSE", "AS", "ORDER", "BY", "ASC", "DESC", "LIMIT", "OFFSET", "GROUP", "HAVING", "DISTINCT", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "ON", "UNION", "INTERSECT", "EXCEPT", "ALL", "WITH", "CASE", "WHEN", "THEN", "ELSE", "END", "OVER", "PARTITION", "ROWS", "RANGE", "UNBOUNDED", "PRECEDING", "FOLLOWING", "CURRENT", "ROW", "NULLS", "FIRST", "LAST", ]; /** * Create keyword completions from the SQL keywords list */ function createKeywordCompletions(): Completion[] { return SQL_KEYWORDS.map((keyword) => ({ label: keyword, type: "keyword", boost: -1, // Keywords should have lower priority than schema items })); } /** * Create function completions from TSQL function definitions */ function createFunctionCompletions(): Completion[] { const functions: Completion[] = []; // Add regular functions for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) { // Skip internal functions starting with _ if (name.startsWith("_")) continue; const argsHint = meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`; functions.push({ label: name, type: "function", detail: argsHint, apply: `${name}()`, }); } // Add aggregate functions with slightly higher boost for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) { if (name.startsWith("_")) continue; const argsHint = meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`; functions.push({ label: name, type: "function", detail: `aggregate ${argsHint}`, apply: `${name}()`, boost: 0.5, }); } // Add special TSQL functions not in the ClickHouse function registry functions.push({ label: "timeBucket", type: "function", detail: "auto time bucket (0 args)", apply: "timeBucket()", boost: 1.5, info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.", }); return functions; } /** * Create table completions from schema */ function createTableCompletions(schema: TableSchema[]): Completion[] { return schema.map((table) => ({ label: table.name, type: "class", // Using "class" type for tables gives them a nice icon detail: table.description || "table", boost: 1, // Tables should have higher priority })); } /** * Create column completions for a specific table */ function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] { const columns: Completion[] = []; for (const [name, column] of Object.entries(table.columns)) { columns.push({ label: prefix ? `${prefix}.${name}` : name, type: "property", // Using "property" type for columns detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`, boost: 2, // Columns should have highest priority }); } return columns; } /** * Extract table names/aliases from the current query context * This is a simplified parser that looks for FROM and JOIN clauses */ function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map { const tableMap = new Map(); const _tableNames = schema.map((t) => t.name); // Simple regex to find table references in FROM and JOIN clauses // Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias const tablePattern = /(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi; let match; while ((match = tablePattern.exec(doc)) !== null) { const tableName = match[1]; const alias = match[2] || tableName; // Find the table schema if it exists const tableSchema = schema.find((t) => t.name.toLowerCase() === tableName.toLowerCase()); if (tableSchema) { tableMap.set(alias.toLowerCase(), tableSchema); } } return tableMap; } /** * Determine what context we're in based on cursor position */ type CompletionContextType = | "table" // After FROM or JOIN | "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc. | "alias" // After table_name. | "value" // After comparison operator (=, !=, IN, etc.) | "general"; // Anywhere else /** * Result of context detection */ interface ContextResult { type: CompletionContextType; tablePrefix?: string; /** Column being compared (for value context) */ columnName?: string; /** Table alias for the column (for value context) */ columnTableAlias?: string; } /** * Extract column name from text before a comparison operator * Handles: "column =", "table.column =", "column IN", "column = 'partial", etc. */ function extractColumnBeforeOperator( textBefore: string ): { columnName: string; tableAlias?: string } | null { // Match patterns like: column =, column !=, column IN, table.column =, etc. // We need to capture the column (and optional table prefix) before the operator // Also match when user is typing a partial string value like: column = 'val const patterns = [ // column = or column != or column <> (with optional whitespace and optional partial string value) /(\w+)\.(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i, /(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i, // column IN ( or column NOT IN ( (with optional partial string value) /(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i, /(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i, // After a comma in IN clause (with optional partial string value) /(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i, /(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i, ]; for (const pattern of patterns) { const match = textBefore.match(pattern); if (match) { if (match.length === 3) { // table.column pattern return { tableAlias: match[1], columnName: match[2] }; } else { // just column pattern return { columnName: match[1] }; } } } return null; } function determineContext(doc: string, pos: number): ContextResult { // Get text before cursor const textBefore = doc.slice(0, pos); // Check if we're in a value context (after comparison operator) // This should be checked before other contexts const columnInfo = extractColumnBeforeOperator(textBefore); if (columnInfo) { return { type: "value", columnName: columnInfo.columnName, columnTableAlias: columnInfo.tableAlias, }; } // Check if we're completing after a dot (table.column) const dotMatch = textBefore.match(/(\w+)\.\s*$/); if (dotMatch) { return { type: "alias", tablePrefix: dotMatch[1] }; } // Find the LAST significant keyword before cursor // We match all keywords and take the last one const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi; let lastMatch: RegExpExecArray | null = null; let match: RegExpExecArray | null; while ((match = keywordPattern.exec(textBefore)) !== null) { lastMatch = match; } if (lastMatch) { const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " "); if (keyword === "FROM" || keyword === "JOIN") { return { type: "table" }; } if ( keyword === "SELECT" || keyword === "WHERE" || keyword === "AND" || keyword === "OR" || keyword === "ORDER BY" || keyword === "GROUP BY" || keyword === "HAVING" || keyword === "ON" ) { return { type: "column" }; } } return { type: "general" }; } /** * Find a column schema by name in the tables map */ function findColumnSchema( columnName: string, tableAlias: string | undefined, tables: Map ): ColumnSchema | null { if (tableAlias) { // Look in specific table const tableSchema = tables.get(tableAlias.toLowerCase()); if (tableSchema) { return tableSchema.columns[columnName] || null; } } else { // Look in all tables for (const tableSchema of tables.values()) { const col = tableSchema.columns[columnName]; if (col) { return col; } } } return null; } /** * Create completions for enum values from allowedValues */ function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] { if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) { return []; } return columnSchema.allowedValues.map((value) => ({ label: `'${value}'`, type: "enum", detail: columnSchema.description || "allowed value", boost: 3, // Highest priority for enum values in value context })); } /** * Create a TSQL-aware autocompletion source * * @param schema - Array of table schemas to use for completions * @returns A CodeMirror completion source function */ export function createTSQLCompletion( schema: TableSchema[] ): (context: CompletionContext) => CompletionResult | null { // Pre-compute static completions const keywordCompletions = createKeywordCompletions(); const functionCompletions = createFunctionCompletions(); const tableCompletions = createTableCompletions(schema); return (context: CompletionContext): CompletionResult | null => { // Get the word being typed - include single quotes for value completion const word = context.matchBefore(/[\w.']+/); // Don't show completions if no word is being typed and not explicitly triggered if (!word && !context.explicit) { return null; } const from = word ? word.from : context.pos; const doc = context.state.doc.toString(); const queryContext = determineContext(doc, context.pos); let options: Completion[] = []; // Track if we need to extend replacement range (e.g., to consume auto-paired closing quote) let to: number | undefined = undefined; switch (queryContext.type) { case "table": // After FROM or JOIN, show only tables options = tableCompletions; break; case "alias": // After table., show columns for that table if (queryContext.tablePrefix) { const tables = extractTablesFromQuery(doc, schema); const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase()); if (tableSchema) { options = createColumnCompletions(tableSchema); } } break; case "value": // After comparison operator, show enum values if available if (queryContext.columnName) { const tables = extractTablesFromQuery(doc, schema); const columnSchema = findColumnSchema( queryContext.columnName, queryContext.columnTableAlias, tables ); if (columnSchema) { options = createEnumValueCompletions(columnSchema); // Check if there's a closing quote right after cursor (from auto-pairing) // If so, extend replacement range to include it to avoid 'Completed'' const charAfterCursor = context.state.doc.sliceString(context.pos, context.pos + 1); if (charAfterCursor === "'") { to = context.pos + 1; } } } break; case "column": // After SELECT, WHERE, etc., show columns, functions, and some keywords { const tables = extractTablesFromQuery(doc, schema); // Add columns from all tables in the query tables.forEach((tableSchema, alias) => { // If multiple tables, prefix with alias const prefix = tables.size > 1 ? alias : undefined; options.push(...createColumnCompletions(tableSchema, prefix)); }); // Also add functions and relevant keywords options.push(...functionCompletions); options.push( ...keywordCompletions.filter((k) => [ "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "IS", "NULL", "AS", "CASE", "WHEN", "THEN", "ELSE", "END", ].includes(k.label as string) ) ); } break; case "general": default: // Show everything options = [...tableCompletions, ...functionCompletions, ...keywordCompletions]; // Also add columns from tables in query { const tables = extractTablesFromQuery(doc, schema); tables.forEach((tableSchema, alias) => { const prefix = tables.size > 1 ? alias : undefined; options.push(...createColumnCompletions(tableSchema, prefix)); }); } break; } const result: CompletionResult = { from, options, validFor: /^[\w.']*$/, }; // Only set 'to' if we need to extend the replacement range if (to !== undefined) { result.to = to; } return result; }; }