feat(sql): add ClickHouse function completion

This commit is contained in:
Justin Gao authored and GitHub committed 2026-07-31 14:38:50 +08:00
1 parent a83925d6ba
commit 9bc1c1b0fb
16 files changed
+2326 -53

No files matched your search

+167 -17
View File
@@ -2,6 +2,8 @@ import { Cassandra, MariaSQL, MSSQL, MySQL, PLSQL, PostgreSQL, SQLite, StandardS
import type { DatabaseType, SqlSnippet } from "@/types/database";
import { buildMongoCompletionItemsFromContext, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { CLOUDFLARE_D1_COMMON_FUNCTION_NAMES } from "@/lib/sql/cloudflareD1";
import { searchClickHouseFunctions } from "@/lib/sql/clickhouse/functionRegistry";
import type { ClickHouseFunctionDefinition, ClickHouseFunctionKind } from "@/lib/sql/clickhouse/functionTypes";
import type { SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
@@ -1253,15 +1255,26 @@ export interface SqlCompletionContext {
oracleTableFunctionContext?: boolean;
autoAliasTableCompletions: boolean;
tableAliasAfterCursor?: boolean;
openingParenAfterCursor: boolean;
contextKind: SqlCompletionContextKind;
dataTypeContext: boolean;
}
export interface SqlFunctionSignatureHelpOverload {
signature: string;
parameterGroups: string[][];
activeGroup: number;
activeParameter: number;
}
export interface SqlFunctionSignatureHelp {
name: string;
signature: string;
activeParameter: number;
parameters: string[];
overloads: SqlFunctionSignatureHelpOverload[];
activeOverload: number;
/** Legacy single-overload fields retained for non-ClickHouse callers. */
signature?: string;
activeParameter?: number;
parameters?: string[];
}
export interface SqlCompletionTranslations {
@@ -1346,7 +1359,7 @@ class SqlCompletionProvider {
this.items.push(...buildSnippetItems(context.prefix, snippets, this.input.keywordCase, this.databaseType));
}
if (!preferReferencedColumns || context.suggestRoutines) {
const functionItems = context.dataTypeContext ? [] : buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType);
const functionItems = context.dataTypeContext ? [] : buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType, context.openingParenAfterCursor);
this.items.push(...(preferReferencedColumns ? functionItems.filter((item) => item.label.toLowerCase().startsWith(context.prefix.toLowerCase())) : functionItems));
if (isOracleLikeDatabase(this.databaseType)) {
this.items.push(...buildOracleSystemValueItems(context.prefix, this.input.keywordCase));
@@ -1402,6 +1415,9 @@ class SqlCompletionProvider {
if (!context.exclusiveColumnSuggestions && context.suggestTables) {
this.items.push(...buildForeignKeyRelatedTableItems(context, this.input.tables, this.input.foreignKeysByTable, this.dialect));
this.items.push(...buildTableItems(context, this.input.tables, this.dialect, !!this.input.autoAliasTables && context.autoAliasTableCompletions, context.referencedTables, this.databaseType, this.input.currentSchema));
if (this.databaseType === "clickhouse") {
this.items.push(...buildClickHouseFunctionItems(context.prefix, context.openingParenAfterCursor, "table"));
}
if (isOracleLikeDatabase(this.databaseType)) {
this.items.push(...buildOracleTableFunctionItems(context.prefix));
}
@@ -1630,25 +1646,63 @@ export function getSqlCompletionResultValidFor(sql: string, cursor: number): Reg
export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databaseType?: DatabaseType): SqlFunctionSignatureHelp | null {
const beforeCursor = sql.slice(0, cursor);
const openParenIndex = findActiveFunctionOpenParen(beforeCursor);
if (openParenIndex == null) return null;
const call = findActiveFunctionCall(beforeCursor);
if (!call) return null;
const beforeParen = beforeCursor.slice(0, openParenIndex).trimEnd();
const name = /([A-Za-z_][\w$]*)$/.exec(beforeParen)?.[1]?.toUpperCase();
if (!name) return null;
const observedParameter = countTopLevelCommas(call.groupText);
if (databaseType !== "clickhouse") {
const lookupName = call.name.toUpperCase();
const parameters = (databaseType ? DATABASE_FUNCTION_SIGNATURES[databaseType]?.get(lookupName) : undefined) ?? SQL_FUNCTION_SIGNATURES.get(lookupName);
if (!parameters) return null;
const activeParameter = Math.min(observedParameter, Math.max(0, parameters.length - 1));
const signature = `${lookupName}(${parameters.join(", ")})`;
const legacyHelp = { name: lookupName, signature, activeParameter, parameters };
Object.defineProperties(legacyHelp, {
overloads: {
value: [{ signature, parameterGroups: [parameters], activeGroup: 0, activeParameter }],
enumerable: false,
},
activeOverload: { value: 0, enumerable: false },
});
return legacyHelp as SqlFunctionSignatureHelp;
}
const parameters = (databaseType ? DATABASE_FUNCTION_SIGNATURES[databaseType]?.get(name) : undefined) ?? SQL_FUNCTION_SIGNATURES.get(name);
if (!parameters) return null;
const parameterGroups = searchClickHouseFunctions(call.name, 50)
.find((definition) => [definition.name, ...(definition.aliases ?? [])].some((name) => name.toLowerCase() === call.name.toLowerCase()))
?.signatures.map((signature) => signature.parameterGroups);
if (!parameterGroups) return null;
const overloads = parameterGroups
.map((groups, sourceIndex) => ({ groups, sourceIndex }))
.filter(({ groups }) => groups[call.activeGroup] != null)
.sort((left, right) => {
const leftAccepts = functionParameterGroupAccepts(left.groups[call.activeGroup], observedParameter);
const rightAccepts = functionParameterGroupAccepts(right.groups[call.activeGroup], observedParameter);
return Number(rightAccepts) - Number(leftAccepts) || left.sourceIndex - right.sourceIndex;
})
.map(({ groups }) => {
const parameters = groups[call.activeGroup];
return {
signature: call.name + groups.map((group) => `(${group.join(", ")})`).join(""),
parameterGroups: groups,
activeGroup: call.activeGroup,
activeParameter: Math.min(observedParameter, Math.max(0, parameters.length - 1)),
};
});
if (overloads.length === 0) return null;
const activeParameter = countTopLevelCommas(beforeCursor.slice(openParenIndex + 1));
return {
name,
signature: `${name}(${parameters.join(", ")})`,
activeParameter: Math.min(activeParameter, Math.max(0, parameters.length - 1)),
parameters,
name: call.name,
overloads,
activeOverload: 0,
};
}
function functionParameterGroupAccepts(parameters: string[], observedParameter: number): boolean {
if (observedParameter < parameters.length) return true;
return parameters.some((parameter) => parameter.startsWith("..."));
}
function sqlCompletionStatementSpan(sql: string, cursor: number, options: SqlSemanticBuildOptions): SqlSemanticSpan {
const activeStatementSpan = activeSqlCompletionStatementSpan(sql, cursor, options);
return currentSqlLikeLineBlockSpan(sql, cursor, activeStatementSpan) ?? activeStatementSpan;
@@ -1836,6 +1890,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq
oracleTableFunctionContext,
autoAliasTableCompletions,
tableAliasAfterCursor,
openingParenAfterCursor: /^\s*\(/.test(sql.slice(cursor)),
contextKind,
dataTypeContext,
};
@@ -3991,7 +4046,46 @@ function activeFunctionSignatures(databaseType?: DatabaseType): Map<string, stri
return signatures;
}
function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map<string, string>, databaseType?: DatabaseType): SqlCompletionItem[] {
function formatFunctionSignatureApply(definition: ClickHouseFunctionDefinition, omitOpeningParen: boolean): string {
if (omitOpeningParen) return definition.name;
const signature = definition.signatures[definition.preferredSignature ?? 0];
return (
definition.name +
signature.parameterGroups
.map(
(group) =>
`(${group
.filter((parameter) => !parameter.endsWith("?"))
.map((parameter) => `\${${parameter}}`)
.join(", ")})`,
)
.join("")
);
}
function clickHouseFunctionDetail(definition: ClickHouseFunctionDefinition): string {
const status = definition.status && definition.status !== "stable" ? ` · ${definition.status}` : "";
const overloads = definition.signatures.length > 1 ? ` · ${definition.signatures.length} overloads` : "";
return `ClickHouse · ${definition.category}${overloads}${status}`;
}
function buildClickHouseFunctionItems(prefix: string, omitOpeningParen: boolean, kind?: ClickHouseFunctionKind): SqlCompletionItem[] {
return searchClickHouseFunctions(prefix, 200, kind).map((definition) => {
const statusPenalty = definition.status === "deprecated" ? -600 : definition.status === "experimental" ? -300 : 0;
const generatedPenalty = definition.generated ? -75 : 0;
return {
label: definition.name,
type: "function" as const,
detail: clickHouseFunctionDetail(definition),
info: definition.description,
apply: formatFunctionSignatureApply(definition, omitOpeningParen),
boost: computeBoost(definition.name, prefix) + 300 + statusPenalty + generatedPenalty,
};
});
}
function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map<string, string>, databaseType?: DatabaseType, omitOpeningParen = false): SqlCompletionItem[] {
if (databaseType === "clickhouse") return buildClickHouseFunctionItems(prefix, omitOpeningParen);
const items: SqlCompletionItem[] = [];
for (const [name, parameters] of activeFunctionSignatures(databaseType).entries()) {
@@ -4288,6 +4382,62 @@ function getTypePriorityBoost(type: SqlCompletionItem["type"]): number {
}
}
interface ActiveFunctionCall {
name: string;
activeGroup: number;
groupText: string;
}
function findActiveFunctionCall(sqlBeforeCursor: string): ActiveFunctionCall | null {
const activeOpenParen = findActiveFunctionOpenParen(sqlBeforeCursor);
if (activeOpenParen == null) return null;
const beforeActiveGroup = sqlBeforeCursor.slice(0, activeOpenParen).trimEnd();
const ordinaryName = /([A-Za-z_][\w$]*)$/.exec(beforeActiveGroup)?.[1];
if (ordinaryName) {
return {
name: ordinaryName,
activeGroup: 0,
groupText: sqlBeforeCursor.slice(activeOpenParen + 1),
};
}
if (!beforeActiveGroup.endsWith(")")) return null;
const firstGroupOpenParen = findMatchingOpenParen(beforeActiveGroup, beforeActiveGroup.length - 1);
if (firstGroupOpenParen == null) return null;
const parametricName = /([A-Za-z_][\w$]*)$/.exec(beforeActiveGroup.slice(0, firstGroupOpenParen).trimEnd())?.[1];
if (!parametricName) return null;
return {
name: parametricName,
activeGroup: 1,
groupText: sqlBeforeCursor.slice(activeOpenParen + 1),
};
}
function findMatchingOpenParen(text: string, closeParenIndex: number): number | null {
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let index = closeParenIndex; index >= 0; index -= 1) {
const character = text[index];
if (character === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (character === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (inSingleQuote || inDoubleQuote) continue;
if (character === ")") depth += 1;
else if (character === "(") {
depth -= 1;
if (depth === 0) return index;
}
}
return null;
}
function findActiveFunctionOpenParen(sqlBeforeCursor: string): number | null {
let depth = 0;
let inSingleQuote = false;