diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 49b72b168..d0a131782 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -20,6 +20,7 @@ import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns"; import { formatSqlText, compressSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits"; +import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; import { formatMongoShellText } from "@/lib/mongo/mongoFormatter"; @@ -1831,41 +1832,6 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows return dom; } -function createSignatureDom(signature: ReturnType) { - const dom = document.createElement("div"); - dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"; - if (!signature) return dom; - - const signatureNode = document.createElement("div"); - signatureNode.className = "font-mono"; - - const nameNode = document.createElement("span"); - nameNode.className = "text-muted-foreground"; - nameNode.textContent = `${signature.name}(`; - signatureNode.appendChild(nameNode); - - signature.parameters.forEach((parameter, index) => { - if (index > 0) { - const comma = document.createElement("span"); - comma.className = "text-muted-foreground"; - comma.textContent = ", "; - signatureNode.appendChild(comma); - } - const parameterNode = document.createElement("span"); - parameterNode.className = index === signature.activeParameter ? "font-semibold text-foreground" : "text-muted-foreground"; - parameterNode.textContent = parameter; - signatureNode.appendChild(parameterNode); - }); - - const closeNode = document.createElement("span"); - closeNode.className = "text-muted-foreground"; - closeNode.textContent = ")"; - signatureNode.appendChild(closeNode); - dom.appendChild(signatureNode); - - return dom; -} - async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) { if (!props.connectionId || props.database == null || contextMenuOpen.value) return null; @@ -3733,7 +3699,7 @@ onMounted(async () => { pos: currentState.selection.main.head, above: false, clip: false, - create: () => ({ dom: createSignatureDom(signature) }), + create: () => ({ dom: createSqlSignatureTooltipDom(signature) }), }; }); diff --git a/apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts b/apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts new file mode 100644 index 000000000..4ac6daffb --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip"; + +describe("SQL signature tooltip", () => { + it("renders every overload and highlights the active parameter in each one", () => { + const dom = createSqlSignatureTooltipDom({ + name: "toStartOfInterval", + activeOverload: 0, + overloads: [ + { + signature: "toStartOfInterval(value, interval)", + parameterGroups: [["value", "interval"]], + activeGroup: 0, + activeParameter: 1, + }, + { + signature: "toStartOfInterval(value, interval, time_zone)", + parameterGroups: [["value", "interval", "time_zone"]], + activeGroup: 0, + activeParameter: 1, + }, + ], + }); + + expect(dom.textContent).toContain("1/2"); + expect(dom.textContent).toContain("2/2"); + expect(dom.textContent).toContain("time_zone"); + expect(dom.querySelectorAll("[data-active-parameter='true']")).toHaveLength(2); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts b/apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts new file mode 100644 index 000000000..c167a1ee4 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { generateAggregateCombinatorCandidates } from "@/lib/sql/clickhouse/aggregateCombinators"; +import { CLICKHOUSE_FUNCTION_REGISTRY } from "@/lib/sql/clickhouse/functionRegistry"; + +describe("ClickHouse aggregate combinators", () => { + it("generates If with an appended condition argument", () => { + const sumIf = generateAggregateCombinatorCandidates("sumIf", 20).find((item) => item.name === "sumIf"); + expect(sumIf?.signatures[0].parameterGroups).toEqual([["value", "condition"]]); + }); + + it("allows Array before If and rejects the reverse order", () => { + expect(generateAggregateCombinatorCandidates("uniqArrayIf", 20).some((item) => item.name === "uniqArrayIf")).toBe(true); + expect(generateAggregateCombinatorCandidates("uniqIfArray", 20).some((item) => item.name === "uniqIfArray")).toBe(false); + }); + + it("preserves parametric aggregate groups for State", () => { + const state = generateAggregateCombinatorCandidates("quantilesTDigestState", 20).find((item) => item.name === "quantilesTDigestState"); + expect(state?.signatures[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]); + }); + + it("bounds generated results", () => { + expect(generateAggregateCombinatorCandidates("", 7)).toHaveLength(7); + }); + + it("does not generate aggregate combinators for window functions", () => { + for (const name of ["rankIf", "denseRankState", "percentRankIf", "cume_distState", "ntileIf"] as const) { + expect(generateAggregateCombinatorCandidates(name, 20).some((item) => item.name === name)).toBe(false); + } + }); +}); + +it("contains ordinary and parametric aggregate definitions", () => { + expect(CLICKHOUSE_FUNCTION_REGISTRY.get("uniqExact")).toMatchObject({ kind: "aggregate" }); + expect(CLICKHOUSE_FUNCTION_REGISTRY.get("quantilesTDigest")?.signatures[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts b/apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts new file mode 100644 index 000000000..c717cce4c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { CLICKHOUSE_FUNCTION_REGISTRY, createClickHouseFunctionRegistry } from "@/lib/sql/clickhouse/functionRegistry"; +import { CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST, CLICKHOUSE_REGULAR_FUNCTIONS } from "@/lib/sql/clickhouse/regularFunctions"; +import { CLICKHOUSE_TABLE_FUNCTIONS } from "@/lib/sql/clickhouse/tableFunctions"; +import type { ClickHouseFunctionDefinition } from "@/lib/sql/clickhouse/functionTypes"; + +const toStartOfDay: ClickHouseFunctionDefinition = { + name: "toStartOfDay", + kind: "regular", + category: "date-time", + signatures: [{ parameterGroups: [["value", "time_zone?"]], returnType: "DateTime" }], + aliases: ["startOfDay"], +}; + +describe("ClickHouse function registry", () => { + it("looks up canonical names case-insensitively and preserves overloads", () => { + const registry = createClickHouseFunctionRegistry([toStartOfDay]); + + expect(registry.get("TOSTARTOFDAY")).toEqual(toStartOfDay); + expect(registry.search("tostart", 20)).toEqual([toStartOfDay]); + expect(registry.search("startof", 20)).toEqual([toStartOfDay]); + }); + + it("rejects duplicate canonical names case-insensitively", () => { + expect(() => createClickHouseFunctionRegistry([toStartOfDay, { ...toStartOfDay, name: "TOSTARTOFDAY" }])).toThrow(/duplicate/i); + }); + + it("rejects an invalid preferred signature index", () => { + expect(() => createClickHouseFunctionRegistry([{ ...toStartOfDay, preferredSignature: 2 }])).toThrow(/preferred signature/i); + }); + + it("keeps the checked-in category manifest and inventory counts aligned", () => { + for (const entry of CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST) { + expect(CLICKHOUSE_REGULAR_FUNCTIONS.filter((definition) => definition.category === entry.category)).toHaveLength(entry.minimumCount); + } + }); + + it.each([ + ["arrayMap", "array"], + ["toStartOfDay", "date-time"], + ["JSONExtractString", "json"], + ["cityHash64", "hash"], + ["URLHierarchy", "url"], + ["lagInFrame", "window"], + ] as const)("contains %s with canonical casing and category %s", (name, category) => { + expect(CLICKHOUSE_FUNCTION_REGISTRY.get(name)).toMatchObject({ name, category }); + }); + + it("treats names shared with Object.prototype as ordinary ClickHouse functions", () => { + expect(CLICKHOUSE_FUNCTION_REGISTRY.get("toString")?.signatures.length).toBeGreaterThan(0); + }); + + it("models window function aliases and exact argument lists", () => { + for (const name of ["rank", "dense_rank", "denseRank", "percent_rank", "percentRank", "cume_dist"] as const) { + expect(CLICKHOUSE_FUNCTION_REGISTRY.get(name)).toMatchObject({ kind: "window", signatures: [{ parameterGroups: [[]] }] }); + } + expect(CLICKHOUSE_FUNCTION_REGISTRY.get("ntile")).toMatchObject({ + kind: "window", + signatures: [{ parameterGroups: [["buckets"]] }], + }); + }); + + it.each(["numbers", "file", "url", "s3", "remote", "postgresql", "mysql"] as const)("contains the %s table function", (name) => { + expect(CLICKHOUSE_TABLE_FUNCTIONS.some((definition) => definition.name === name && definition.kind === "table")).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index de096e8ee..e6d0e5390 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -15,6 +15,73 @@ describe("sqlCompletion keyword snippets", () => { }); describe("sqlCompletion database functions", () => { + it("suggests ClickHouse functions with canonical casing and preferred placeholders", () => { + const sql = "SELECT tostart"; + const items = buildSqlCompletionItems(sql, sql.length, { + databaseType: "clickhouse", + tables: [], + columnsByTable: new Map(), + }); + + expect(items.find((item) => item.label === "toStartOfDay")).toMatchObject({ + type: "function", + apply: "toStartOfDay(${value})", + }); + }); + + it("uses exact ClickHouse window function placeholders", () => { + const denseRankSql = "SELECT dense_"; + const denseRankItems = buildSqlCompletionItems(denseRankSql, denseRankSql.length, { + databaseType: "clickhouse", + tables: [], + columnsByTable: new Map(), + }); + expect(denseRankItems.find((item) => item.label === "dense_rank")?.apply).toBe("dense_rank()"); + + const ntileSql = "SELECT nti"; + const ntileItems = buildSqlCompletionItems(ntileSql, ntileSql.length, { + databaseType: "clickhouse", + tables: [], + columnsByTable: new Map(), + }); + expect(ntileItems.find((item) => item.label === "ntile")?.apply).toBe("ntile(${buckets})"); + }); + + it("does not leak ClickHouse-only functions to MySQL", () => { + const sql = "SELECT tostart"; + const items = buildSqlCompletionItems(sql, sql.length, { + databaseType: "mysql", + tables: [], + columnsByTable: new Map(), + }); + + expect(items.some((item) => item.label === "toStartOfDay")).toBe(false); + }); + + it("suggests only ClickHouse table functions alongside tables after FROM", () => { + const sql = "SELECT * FROM num"; + const items = buildSqlCompletionItems(sql, sql.length, { + databaseType: "clickhouse", + tables: [{ name: "number_events", type: "table" }], + columnsByTable: new Map(), + }); + + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "numbers", type: "function" }), expect.objectContaining({ label: "number_events", type: "table" })])); + expect(items.some((item) => item.label === "toStartOfDay")).toBe(false); + }); + + it("does not insert a duplicate opening parenthesis before an existing call", () => { + const sql = "SELECT toStart()"; + const cursor = "SELECT toStart".length; + const items = buildSqlCompletionItems(sql, cursor, { + databaseType: "clickhouse", + tables: [], + columnsByTable: new Map(), + }); + + expect(items.find((item) => item.label === "toStartOfDay")?.apply).toBe("toStartOfDay"); + }); + it("suggests MySQL Unix timestamp functions with function snippets", () => { const fromUnixSql = "SELECT from_unix"; const fromUnixItems = buildSqlCompletionItems(fromUnixSql, fromUnixSql.length, { diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts new file mode 100644 index 000000000..8613b0766 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { getSqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion"; + +describe("ClickHouse signature help", () => { + it("returns every overload and highlights the active ordinary parameter", () => { + const sql = "SELECT toStartOfInterval(ts, "; + const help = getSqlFunctionSignatureHelp(sql, sql.length, "clickhouse"); + expect(help?.name).toBe("toStartOfInterval"); + expect(help?.overloads.length).toBeGreaterThan(1); + expect(help?.overloads[0].activeGroup).toBe(0); + expect(help?.overloads[0].activeParameter).toBe(1); + }); + + it("resolves the second parameter group of a parametric aggregate", () => { + const sql = "SELECT quantilesTDigest(0.5, 0.9)(value"; + const help = getSqlFunctionSignatureHelp(sql, sql.length, "clickhouse"); + expect(help?.name).toBe("quantilesTDigest"); + expect(help?.overloads[0]).toMatchObject({ activeGroup: 1, activeParameter: 0 }); + expect(help?.overloads[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]); + }); + + it("keeps MySQL signature help as one overload and one parameter group", () => { + const sql = "SELECT DATE_ADD(created_at, "; + const help = getSqlFunctionSignatureHelp(sql, sql.length, "mysql"); + expect(help?.overloads).toHaveLength(1); + expect(help?.overloads[0].parameterGroups).toEqual([["date", "INTERVAL expr unit"]]); + }); +}); diff --git a/apps/desktop/src/lib/editor/sqlSignatureTooltip.ts b/apps/desktop/src/lib/editor/sqlSignatureTooltip.ts new file mode 100644 index 000000000..d3a1ddb43 --- /dev/null +++ b/apps/desktop/src/lib/editor/sqlSignatureTooltip.ts @@ -0,0 +1,45 @@ +import type { SqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion"; + +export function createSqlSignatureTooltipDom(signature: SqlFunctionSignatureHelp | null): HTMLElement { + const dom = document.createElement("div"); + dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"; + if (!signature) return dom; + + signature.overloads.forEach((overload, overloadIndex) => { + const row = document.createElement("div"); + row.className = overloadIndex > 0 ? "mt-1 flex items-center gap-2 font-mono" : "flex items-center gap-2 font-mono"; + if (signature.overloads.length > 1) { + const count = document.createElement("span"); + count.className = "text-[10px] text-muted-foreground"; + count.textContent = `${overloadIndex + 1}/${signature.overloads.length}`; + row.appendChild(count); + } + + const call = document.createElement("span"); + const name = document.createElement("span"); + name.className = "text-muted-foreground"; + name.textContent = signature.name; + call.appendChild(name); + + overload.parameterGroups.forEach((group, groupIndex) => { + const open = document.createElement("span"); + open.className = "text-muted-foreground"; + open.textContent = "("; + call.appendChild(open); + group.forEach((parameter, parameterIndex) => { + if (parameterIndex > 0) call.append(", "); + const node = document.createElement("span"); + const active = groupIndex === overload.activeGroup && parameterIndex === overload.activeParameter; + node.className = active ? "font-semibold text-foreground" : "text-muted-foreground"; + if (active) node.dataset.activeParameter = "true"; + node.textContent = parameter; + call.appendChild(node); + }); + call.append(")"); + }); + + row.appendChild(call); + dom.appendChild(row); + }); + return dom; +} diff --git a/apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts b/apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts new file mode 100644 index 000000000..460fc09c0 --- /dev/null +++ b/apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts @@ -0,0 +1,115 @@ +import { CLICKHOUSE_AGGREGATE_FUNCTIONS } from "./aggregateFunctions"; +import type { ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes"; + +type CombinatorName = "Array" | "Map" | "ForEach" | "Distinct" | "OrDefault" | "OrNull" | "If" | "Resample" | "SimpleState" | "State" | "Merge" | "MergeState"; + +const COLLECTION_COMBINATORS = [undefined, "Array", "Map", "ForEach"] as const; +const DEFAULT_COMBINATORS = [undefined, "OrDefault", "OrNull"] as const; +const TERMINAL_COMBINATORS = [undefined, "SimpleState", "State", "Merge", "MergeState"] as const; + +function buildCombinatorSequences(): CombinatorName[][] { + const sequences: CombinatorName[][] = []; + for (const collection of COLLECTION_COMBINATORS) { + for (const distinct of [false, true]) { + for (const fallback of DEFAULT_COMBINATORS) { + for (const conditional of [false, true]) { + for (const terminal of TERMINAL_COMBINATORS) { + const sequence: CombinatorName[] = []; + if (collection) sequence.push(collection); + if (distinct) sequence.push("Distinct"); + if (fallback) sequence.push(fallback); + if (conditional) sequence.push("If"); + if (terminal) sequence.push(terminal); + if (sequence.length > 0) sequences.push(sequence); + } + } + } + } + } + + for (const conditional of [false, true]) { + for (const terminal of TERMINAL_COMBINATORS) { + const sequence: CombinatorName[] = ["Resample"]; + if (conditional) sequence.push("If"); + if (terminal) sequence.push(terminal); + sequences.push(sequence); + } + } + return sequences; +} + +const COMBINATOR_SEQUENCES = buildCombinatorSequences(); + +function cloneSignature(signature: ClickHouseFunctionSignature): ClickHouseFunctionSignature { + return { ...signature, parameterGroups: signature.parameterGroups.map((group) => [...group]) }; +} + +function transformLastGroup(signature: ClickHouseFunctionSignature, transform: (group: string[]) => string[]): ClickHouseFunctionSignature { + const transformed = cloneSignature(signature); + const last = transformed.parameterGroups.length - 1; + transformed.parameterGroups[last] = transform(transformed.parameterGroups[last]); + return transformed; +} + +function applyCombinator(signature: ClickHouseFunctionSignature, combinator: CombinatorName): ClickHouseFunctionSignature { + switch (combinator) { + case "Array": + case "ForEach": + return transformLastGroup(signature, (group) => + group.map((parameter, index) => { + if (parameter.startsWith("...")) return "...arrays"; + return index === 0 ? "array" : `array_${index + 1}`; + }), + ); + case "Map": + return transformLastGroup(signature, () => ["map"]); + case "If": + return transformLastGroup(signature, (group) => [...group, "condition"]); + case "Resample": { + const transformed = transformLastGroup(signature, (group) => [...group, "resampling_key"]); + transformed.parameterGroups.splice(Math.max(0, transformed.parameterGroups.length - 1), 0, ["start", "end", "step"]); + return transformed; + } + case "Merge": + case "MergeState": + return transformLastGroup(signature, () => ["state"]); + case "Distinct": + case "OrDefault": + case "OrNull": + case "SimpleState": + case "State": + return cloneSignature(signature); + } +} + +function generateDefinition(base: ClickHouseFunctionDefinition, sequence: readonly CombinatorName[]): ClickHouseFunctionDefinition { + const signatures = base.signatures.map((signature) => sequence.reduce(applyCombinator, signature)); + return { + ...base, + name: `${base.name}${sequence.join("")}`, + signatures, + aliases: undefined, + combinators: false, + generated: true, + }; +} + +export function generateAggregateCombinatorCandidates(prefix: string, limit: number): ClickHouseFunctionDefinition[] { + if (limit <= 0) return []; + const normalized = prefix.toLowerCase(); + const results: ClickHouseFunctionDefinition[] = []; + + for (const base of CLICKHOUSE_AGGREGATE_FUNCTIONS) { + if (base.combinators === false) continue; + const baseName = base.name.toLowerCase(); + if (normalized && !baseName.startsWith(normalized) && !normalized.startsWith(baseName)) continue; + + for (const sequence of COMBINATOR_SEQUENCES) { + const candidate = generateDefinition(base, sequence); + if (!candidate.name.toLowerCase().startsWith(normalized)) continue; + results.push(candidate); + if (results.length === limit) return results; + } + } + return results; +} diff --git a/apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts b/apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts new file mode 100644 index 000000000..870581fae --- /dev/null +++ b/apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts @@ -0,0 +1,112 @@ +import type { ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes"; +import { CLICKHOUSE_WINDOW_FUNCTION_NAMES } from "./regularFunctions"; + +/** + * Static aggregate-function snapshot verified against the official ClickHouse + * Playground on 2026-07-31. Runtime completion never queries a server. + */ +const CLICKHOUSE_AGGREGATE_NAMES = + "aggThrow analysisOfVariance any any_respect_nulls anyHeavy anyLast anyLast_respect_nulls approx_top_k approx_top_sum argAndMax argAndMin argMax argMin avg avgWeighted boundingRatio categoricalInformationValue contingency corr corrMatrix corrStable count covarPop covarPopMatrix covarPopStable covarSamp covarSampMatrix covarSampStable cramersV cramersVBiasCorrected cume_dist deltaSum deltaSumTimestamp denseRank distinctDynamicTypes distinctJSONPaths distinctJSONPathsAndTypes entropy estimateCompressionRatio exponentialMovingAverage exponentialTimeDecayedAvg exponentialTimeDecayedCount exponentialTimeDecayedMax exponentialTimeDecayedSum flameGraph groupArray groupArrayInsertAt groupArrayIntersect groupArrayLast groupArrayMovingAvg groupArrayMovingSum groupArraySample groupArraySorted groupBitAnd groupBitmap groupBitmapAnd groupBitmapOr groupBitmapXor groupBitOr groupBitXor groupConcat groupFormat groupNumericIndexedVector groupUniqArray histogram intervalLengthSum kolmogorovSmirnovTest kurtPop kurtSamp lag lagInFrame largestTriangleThreeBuckets lead leadInFrame mannWhitneyUTest max maxIntersections maxIntersectionsPosition maxMappedArrays meanZTest min minMappedArrays MVTEncode nonNegativeDerivative nothing nothingNull nothingUInt64 nth_value ntile percentRank quantile quantileBFloat16 quantileBFloat16Weighted quantileDD quantileDeterministic quantileExact quantileExactExclusive quantileExactHigh quantileExactInclusive quantileExactLow quantileExactWeighted quantileExactWeightedInterpolated quantileGK quantileInterpolatedWeighted quantilePrometheusHistogram quantiles quantilesBFloat16 quantilesBFloat16Weighted quantilesDD quantilesDeterministic quantilesExact quantilesExactExclusive quantilesExactHigh quantilesExactInclusive quantilesExactLow quantilesExactWeighted quantilesExactWeightedInterpolated quantilesGK quantilesInterpolatedWeighted quantilesPrometheusHistogram quantilesTDigest quantilesTDigestWeighted quantilesTiming quantilesTimingWeighted quantileTDigest quantileTDigestWeighted quantileTiming quantileTimingWeighted rank rankCorr retention row_number sequenceCount sequenceMatch sequenceMatchEvents sequenceNextNode simpleLinearRegression singleValueOrNull skewPop skewSamp sparkbar stddevPop stddevPopStable stddevSamp stddevSampStable stochasticLinearRegression stochasticLogisticRegression studentTTest studentTTestOneSample sum sumCount sumKahan sumMapFiltered sumMapFilteredWithOverflow sumMappedArrays sumMapWithOverflow sumWithOverflow theilsU timeSeriesChangesToGrid timeSeriesDeltaToGrid timeSeriesDerivToGrid timeSeriesGroupArray timeSeriesInstantDeltaToGrid timeSeriesInstantRateToGrid timeSeriesLastTwoSamples timeSeriesPredictLinearToGrid timeSeriesRateToGrid timeSeriesResampleToGridWithStaleness timeSeriesResetsToGrid topK topKWeighted uniq uniqCombined uniqCombined64 uniqExact uniqHLL12 uniqTheta uniqUpTo varPop varPopStable varSamp varSampStable welchTTest windowFunnel"; + +const CLICKHOUSE_AGGREGATE_ALIASES: Record = { + analysisOfVariance: ["anova"], + any: ["any_value"], + any_respect_nulls: ["any_value_respect_nulls", "anyRespectNulls", "anyValueRespectNulls", "first_value_respect_nulls", "firstValueRespectNulls"], + anyLast_respect_nulls: ["anyLastRespectNulls", "last_value_respect_nulls", "lastValueRespectNulls"], + approx_top_k: ["approx_top_count"], + argMax: ["max_by"], + argMin: ["min_by"], + covarPop: ["COVAR_POP"], + covarSamp: ["COVAR_SAMP"], + groupArray: ["array_agg"], + groupBitAnd: ["BIT_AND"], + groupBitOr: ["BIT_OR"], + groupBitXor: ["BIT_XOR"], + groupConcat: ["group_concat", "string_agg"], + largestTriangleThreeBuckets: ["lttb"], + MVTEncode: ["ST_AsMVT"], + quantile: ["median"], + quantileBFloat16: ["medianBFloat16"], + quantileBFloat16Weighted: ["medianBFloat16Weighted"], + quantileDD: ["medianDD"], + quantileDeterministic: ["medianDeterministic"], + quantileExact: ["medianExact"], + quantileExactHigh: ["medianExactHigh"], + quantileExactLow: ["medianExactLow"], + quantileExactWeighted: ["medianExactWeighted"], + quantileExactWeightedInterpolated: ["medianExactWeightedInterpolated"], + quantileGK: ["medianGK"], + quantileInterpolatedWeighted: ["medianInterpolatedWeighted"], + quantileTDigest: ["medianTDigest"], + quantileTDigestWeighted: ["medianTDigestWeighted"], + quantileTiming: ["medianTiming"], + quantileTimingWeighted: ["medianTimingWeighted"], + stddevPop: ["STD", "STDDEV_POP"], + stddevSamp: ["STDDEV", "STDDEV_SAMP"], + timeSeriesResampleToGridWithStaleness: ["timeSeriesLastToGrid"], + varPop: ["VAR_POP"], + varSamp: ["VAR_SAMP"], +}; + +const sig = (...parameterGroups: string[][]): ClickHouseFunctionSignature => ({ parameterGroups }); + +const SIGNATURE_OVERRIDES: Record = { + count: [sig([]), sig(["expression"])], + sum: [sig(["value"])], + sumWithOverflow: [sig(["value"])], + avg: [sig(["value"])], + min: [sig(["value"])], + max: [sig(["value"])], + any: [sig(["value"])], + anyLast: [sig(["value"])], + anyHeavy: [sig(["value"])], + argMin: [sig(["argument", "value"])], + argMax: [sig(["argument", "value"])], + groupArray: [sig(["expression"]), sig(["max_size"], ["expression"])], + groupUniqArray: [sig(["expression"]), sig(["max_size"], ["expression"])], + groupArrayArray: [sig(["array"])], + groupArrayInsertAt: [sig(["default_value?", "size?"]), sig(["value", "position"])], + groupConcat: [sig(["delimiter?", "limit?"]), sig(["expression"])], + uniq: [sig(["expression", "...expressions"])], + uniqExact: [sig(["expression", "...expressions"])], + uniqCombined: [sig(["HLL_precision?"]), sig(["expression", "...expressions"])], + uniqCombined64: [sig(["HLL_precision?"]), sig(["expression", "...expressions"])], + uniqHLL12: [sig(["expression", "...expressions"])], + uniqTheta: [sig(["expression", "...expressions"])], + quantile: [sig(["level?"]), sig(["expression"])], + quantiles: [sig(["level", "...levels"], ["expression"])], + quantileExact: [sig(["level?"]), sig(["expression"])], + quantilesExact: [sig(["level", "...levels"], ["expression"])], + quantileTDigest: [sig(["level?"]), sig(["expression"])], + quantilesTDigest: [sig(["level", "...levels"], ["expression"])], + quantileTiming: [sig(["level?"]), sig(["expression"])], + quantilesTiming: [sig(["level", "...levels"], ["expression"])], + quantileBFloat16: [sig(["level?"]), sig(["expression"])], + quantilesBFloat16: [sig(["level", "...levels"], ["expression"])], + median: [sig(["level?"]), sig(["expression"])], + topK: [sig(["N?", "load_factor?", "counts?"]), sig(["expression"])], + topKWeighted: [sig(["N?", "load_factor?", "counts?"]), sig(["expression", "weight"])], + histogram: [sig(["bins"]), sig(["values"])], + sequenceMatch: [sig(["pattern"]), sig(["timestamp", "...conditions"])], + sequenceCount: [sig(["pattern"]), sig(["timestamp", "...conditions"])], + windowFunnel: [sig(["window", "mode?"]), sig(["timestamp", "...conditions"])], + retention: [sig(["condition", "...conditions"])], +}; + +const aggregate = (name: string): ClickHouseFunctionDefinition => { + const signatures = Object.prototype.hasOwnProperty.call(SIGNATURE_OVERRIDES, name) ? SIGNATURE_OVERRIDES[name] : [sig(["expression", "...expressions?"])]; + const aliases = Object.prototype.hasOwnProperty.call(CLICKHOUSE_AGGREGATE_ALIASES, name) ? CLICKHOUSE_AGGREGATE_ALIASES[name] : undefined; + return { + name, + kind: "aggregate", + category: "aggregate", + signatures, + aliases, + combinators: true, + }; +}; + +export const CLICKHOUSE_AGGREGATE_FUNCTIONS: ClickHouseFunctionDefinition[] = CLICKHOUSE_AGGREGATE_NAMES.split(" ") + .filter(Boolean) + .filter((name) => !CLICKHOUSE_WINDOW_FUNCTION_NAMES.has(name)) + .map(aggregate); diff --git a/apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts b/apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts new file mode 100644 index 000000000..a2b851150 --- /dev/null +++ b/apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts @@ -0,0 +1,50 @@ +import type { ClickHouseFunctionDefinition, ClickHouseFunctionKind, ClickHouseFunctionRegistry } from "./functionTypes"; +import { generateAggregateCombinatorCandidates } from "./aggregateCombinators"; +import { CLICKHOUSE_AGGREGATE_FUNCTIONS } from "./aggregateFunctions"; +import { CLICKHOUSE_REGULAR_FUNCTIONS } from "./regularFunctions"; +import { CLICKHOUSE_TABLE_FUNCTIONS } from "./tableFunctions"; + +function definitionKeys(definition: ClickHouseFunctionDefinition): string[] { + return [definition.name, ...(definition.aliases ?? [])].map((name) => name.toLowerCase()); +} + +function validateDefinition(definition: ClickHouseFunctionDefinition): void { + if (!definition.name.trim()) throw new Error("ClickHouse function name must not be empty"); + if (definition.signatures.length === 0) throw new Error(`ClickHouse function ${definition.name} must define a signature`); + if (definition.signatures.some((signature) => signature.parameterGroups.length === 0)) { + throw new Error(`ClickHouse function ${definition.name} must define a parameter group`); + } + const preferred = definition.preferredSignature ?? 0; + if (preferred < 0 || preferred >= definition.signatures.length) { + throw new Error(`ClickHouse function ${definition.name} has an invalid preferred signature`); + } +} + +export function createClickHouseFunctionRegistry(definitions: readonly ClickHouseFunctionDefinition[]): ClickHouseFunctionRegistry { + const byKey = new Map(); + for (const definition of definitions) { + validateDefinition(definition); + for (const key of definitionKeys(definition)) { + if (byKey.has(key)) throw new Error(`Duplicate ClickHouse function or alias: ${key}`); + byKey.set(key, definition); + } + } + const ordered = [...definitions].sort((left, right) => left.name.localeCompare(right.name)); + return { + get: (name) => byKey.get(name.toLowerCase()), + search: (prefix, limit, kind?: ClickHouseFunctionKind) => { + const normalized = prefix.toLowerCase(); + return ordered.filter((definition) => (!kind || definition.kind === kind) && definitionKeys(definition).some((key) => key.startsWith(normalized))).slice(0, limit); + }, + all: () => ordered, + }; +} + +export const CLICKHOUSE_FUNCTION_REGISTRY = createClickHouseFunctionRegistry([...CLICKHOUSE_REGULAR_FUNCTIONS, ...CLICKHOUSE_AGGREGATE_FUNCTIONS, ...CLICKHOUSE_TABLE_FUNCTIONS]); + +export function searchClickHouseFunctions(prefix: string, limit: number, kind?: ClickHouseFunctionKind): ClickHouseFunctionDefinition[] { + const direct = CLICKHOUSE_FUNCTION_REGISTRY.search(prefix, limit, kind); + if (direct.length >= limit || (kind && kind !== "aggregate")) return direct; + const generated = generateAggregateCombinatorCandidates(prefix, limit - direct.length); + return [...direct, ...generated].slice(0, limit); +} diff --git a/apps/desktop/src/lib/sql/clickhouse/functionTypes.ts b/apps/desktop/src/lib/sql/clickhouse/functionTypes.ts new file mode 100644 index 000000000..b96abf285 --- /dev/null +++ b/apps/desktop/src/lib/sql/clickhouse/functionTypes.ts @@ -0,0 +1,28 @@ +export type ClickHouseFunctionKind = "regular" | "aggregate" | "window" | "table"; +export type ClickHouseFunctionStatus = "stable" | "experimental" | "deprecated"; + +export type ClickHouseFunctionCategory = "aggregate" | "array" | "bitmap" | "comparison" | "conversion" | "date-time" | "dictionary" | "encoding" | "geo" | "hash" | "ip" | "json" | "map" | "math" | "nullable" | "random" | "string" | "table" | "tuple" | "url" | "window" | "other"; + +export interface ClickHouseFunctionSignature { + parameterGroups: string[][]; + returnType?: string; +} + +export interface ClickHouseFunctionDefinition { + name: string; + kind: ClickHouseFunctionKind; + category: ClickHouseFunctionCategory; + signatures: ClickHouseFunctionSignature[]; + description?: string; + preferredSignature?: number; + status?: ClickHouseFunctionStatus; + aliases?: string[]; + combinators?: boolean; + generated?: boolean; +} + +export interface ClickHouseFunctionRegistry { + get(name: string): ClickHouseFunctionDefinition | undefined; + search(prefix: string, limit: number, kind?: ClickHouseFunctionKind): ClickHouseFunctionDefinition[]; + all(): readonly ClickHouseFunctionDefinition[]; +} diff --git a/apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts b/apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts new file mode 100644 index 000000000..e660cecab --- /dev/null +++ b/apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts @@ -0,0 +1,288 @@ +import type { ClickHouseFunctionCategory, ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes"; + +/** + * Static name snapshot from ClickHouse Playground system.functions on 2026-07-31. + * Runtime completion never queries a ClickHouse server. + */ +const CLICKHOUSE_REGULAR_NAMES_BY_CATEGORY: Partial> = { + array: + "array arrayAll arrayAUCPR arrayAutocorrelation arrayAvg arrayBottomK arrayCompact arrayConcat arrayCount arrayCumSum arrayCumSumNonNegative arrayDifference arrayDistinct arrayDotProduct arrayElement arrayElementOrNull arrayEnumerate arrayEnumerateDense arrayEnumerateDenseRanked arrayEnumerateUniq arrayEnumerateUniqRanked arrayExcept arrayExists arrayFill arrayFilter arrayFirst arrayFirstIndex arrayFirstOrNull arrayFlatten arrayFold arrayIntersect arrayJaccardIndex arrayJoin arrayLast arrayLastIndex arrayLastOrNull arrayLevenshteinDistance arrayLevenshteinDistanceWeighted arrayMap arrayMax arrayMin arrayNormalizedGini arrayPartialReverseSort arrayPartialShuffle arrayPartialSort arrayPopBack arrayPopFront arrayProduct arrayPushBack arrayPushFront arrayRandomSample arrayReduce arrayReduceInRanges arrayRemove arrayResize arrayReverse arrayReverseFill arrayReverseSort arrayReverseSplit arrayROCAUC arrayRotateLeft arrayRotateRight arrayShiftLeft arrayShiftRight arrayShingles arrayShuffle arraySimilarity arraySlice arraySort arraySplit arrayStringConcat arraySum arraySymmetricDifference arrayTopK arrayTranspose arrayUnion arrayUniq arrayWithConstant arrayZip arrayZipUnaligned", + bitmap: + "bitmapAnd bitmapAndCardinality bitmapAndnot bitmapAndnotCardinality bitmapBuild bitmapCardinality bitmapContains bitmapHasAll bitmapHasAny bitmapMax bitmapMin bitmapOr bitmapOrCardinality bitmapSubsetInRange bitmapSubsetLimit bitmapToArray bitmapTransform bitmapXor bitmapXorCardinality bitmaskToArray bitmaskToList subBitmap", + comparison: + "and empty emptyArrayDate emptyArrayDateTime emptyArrayFloat32 emptyArrayFloat64 emptyArrayInt16 emptyArrayInt32 emptyArrayInt64 emptyArrayInt8 emptyArrayString emptyArrayToSingle emptyArrayUInt16 emptyArrayUInt32 emptyArrayUInt64 emptyArrayUInt8 equals globalIn globalInIgnoreSet globalNotIn globalNotInIgnoreSet globalNotNullIn globalNotNullInIgnoreSet greater greaterOrEquals greatest has hasAll hasAllTokens hasAny hasAnyTokens if in isDistinctFrom isNotDistinctFrom least less lessOrEquals multiIf not notEmpty notEquals notIn notInIgnoreSet or xor", + conversion: + "accurateCast accurateCastOrDefault accurateCastOrNull CAST defaultValueOfArgumentType defaultValueOfTypeName dynamicElement dynamicType materialize reinterpret reinterpretAsDate reinterpretAsDateTime reinterpretAsFixedString reinterpretAsFloat32 reinterpretAsFloat64 reinterpretAsInt128 reinterpretAsInt16 reinterpretAsInt256 reinterpretAsInt32 reinterpretAsInt64 reinterpretAsInt8 reinterpretAsString reinterpretAsUInt128 reinterpretAsUInt16 reinterpretAsUInt256 reinterpretAsUInt32 reinterpretAsUInt64 reinterpretAsUInt8 reinterpretAsUUID toBFloat16 toBFloat16OrNull toBFloat16OrZero toBool toColumnTypeName toDecimal128 toDecimal128OrDefault toDecimal128OrNull toDecimal128OrZero toDecimal256 toDecimal256OrDefault toDecimal256OrNull toDecimal256OrZero toDecimal32 toDecimal32OrDefault toDecimal32OrNull toDecimal32OrZero toDecimal64 toDecimal64OrDefault toDecimal64OrNull toDecimal64OrZero toDecimalString toFixedString toFloat32 toFloat32OrDefault toFloat32OrNull toFloat32OrZero toFloat64 toFloat64OrDefault toFloat64OrNull toFloat64OrZero toInt128 toInt128OrDefault toInt128OrNull toInt128OrZero toInt16 toInt16OrDefault toInt16OrNull toInt16OrZero toInt256 toInt256OrDefault toInt256OrNull toInt256OrZero toInt32 toInt32OrDefault toInt32OrNull toInt32OrZero toInt64 toInt64OrDefault toInt64OrNull toInt64OrZero toInt8 toInt8OrDefault toInt8OrNull toInt8OrZero toInterval toIntervalDay toIntervalHour toIntervalMicrosecond toIntervalMillisecond toIntervalMinute toIntervalMonth toIntervalNanosecond toIntervalQuarter toIntervalSecond toIntervalWeek toIntervalYear toIPv4 toIPv4OrDefault toIPv4OrNull toIPv4OrZero toIPv6 toIPv6OrDefault toIPv6OrNull toIPv6OrZero toLowCardinality toNullable toString toStringCutToZero toTypeName toUInt128 toUInt128OrDefault toUInt128OrNull toUInt128OrZero toUInt16 toUInt16OrDefault toUInt16OrNull toUInt16OrZero toUInt256 toUInt256OrDefault toUInt256OrNull toUInt256OrZero toUInt32 toUInt32OrDefault toUInt32OrNull toUInt32OrZero toUInt64 toUInt64OrDefault toUInt64OrNull toUInt64OrZero toUInt8 toUInt8OrDefault toUInt8OrNull toUInt8OrZero toUUID toUUIDOrDefault toUUIDOrNull toUUIDOrZero variantElement variantType", + "date-time": + "addDate addDays addHours addInterval addMicroseconds addMilliseconds addMinutes addMonths addNanoseconds addQuarters addSeconds addWeeks addYears age changeDay changeHour changeMinute changeMonth changeSecond changeYear DATE dateDiff dateName dateTime64ToSnowflakeID dateTimeToSnowflakeID dateTimeToUUIDv7 dateTrunc formatDateTime formatDateTimeInJodaSyntax fromDaysSinceYearZero fromDaysSinceYearZero32 fromModifiedJulianDay fromModifiedJulianDayOrNull fromUnixTimestamp fromUnixTimestamp64Micro fromUnixTimestamp64Milli fromUnixTimestamp64Nano fromUnixTimestamp64Second fromUnixTimestampInJodaSyntax fromUTCTimestamp hop hopEnd hopStart makeDate makeDate32 makeDateTime makeDateTime64 monthName now now64 nowInBlock nowInBlock64 parseDateTime parseDateTime32BestEffort parseDateTime32BestEffortOrNull parseDateTime32BestEffortOrZero parseDateTime64 parseDateTime64BestEffort parseDateTime64BestEffortOrNull parseDateTime64BestEffortOrZero parseDateTime64BestEffortUS parseDateTime64BestEffortUSOrNull parseDateTime64BestEffortUSOrZero parseDateTime64InJodaSyntax parseDateTime64InJodaSyntaxOrNull parseDateTime64InJodaSyntaxOrZero parseDateTime64OrNull parseDateTime64OrZero parseDateTimeBestEffort parseDateTimeBestEffortOrNull parseDateTimeBestEffortOrZero parseDateTimeBestEffortUS parseDateTimeBestEffortUSOrNull parseDateTimeBestEffortUSOrZero parseDateTimeInJodaSyntax parseDateTimeInJodaSyntaxOrNull parseDateTimeInJodaSyntaxOrZero parseDateTimeOrNull parseDateTimeOrZero parseTimeDelta snowflakeIDToDateTime snowflakeIDToDateTime64 subtractDays subtractHours subtractInterval subtractMicroseconds subtractMilliseconds subtractMinutes subtractMonths subtractNanoseconds subtractQuarters subtractSeconds subtractWeeks subtractYears timeDiff timeSeriesCopyTag timeSeriesCopyTags timeSeriesExtractTag timeSeriesFromGrid timeSeriesGroupToSamplingKey timeSeriesGroupToTags timeSeriesIdToGroup timeSeriesIdToTags timeSeriesJoinTags timeSeriesRange timeSeriesRemoveAllTagsExcept timeSeriesRemoveTag timeSeriesRemoveTags timeSeriesReplaceTag timeSeriesStoreTags timeSeriesTagsToGroup timeSeriesThrowDuplicateSeriesIf timeSlot timeSlots timestamp timezone timezoneOf timezoneOffset toDate toDate32 toDate32OrDefault toDate32OrNull toDate32OrZero toDateOrDefault toDateOrNull toDateOrZero toDateTime toDateTime32 toDateTime64 toDateTime64OrDefault toDateTime64OrNull toDateTime64OrZero toDateTimeOrDefault toDateTimeOrNull toDateTimeOrZero today toDayOfMonth toDayOfWeek toDayOfYear toDaysInMonth toDaysSinceYearZero toHour toISOWeek toISOYear toLastDayOfMonth toLastDayOfWeek toMicrosecond toMillisecond toMinute toModifiedJulianDay toModifiedJulianDayOrNull toMonday toMonth toMonthNumSinceEpoch toNanosecond toQuarter toRelativeDayNum toRelativeHourNum toRelativeMinuteNum toRelativeMonthNum toRelativeQuarterNum toRelativeSecondNum toRelativeWeekNum toRelativeYearNum toSecond toStartOfDay toStartOfFifteenMinutes toStartOfFiveMinutes toStartOfHour toStartOfInterval toStartOfISOYear toStartOfMicrosecond toStartOfMillisecond toStartOfMinute toStartOfMonth toStartOfNanosecond toStartOfQuarter toStartOfSecond toStartOfTenMinutes toStartOfWeek toStartOfYear toTime toTime64 toTime64OrNull toTime64OrZero toTimeOrNull toTimeOrZero toTimeWithFixedDate toTimezone toUnixTimestamp toUnixTimestamp64Micro toUnixTimestamp64Milli toUnixTimestamp64Nano toUnixTimestamp64Second toUTCTimestamp toWeek toYear toYearNumSinceEpoch toYearWeek toYYYYMM toYYYYMMDD toYYYYMMDDhhmmss tumble tumbleEnd tumbleStart ULIDStringToDateTime UTCTimestamp yesterday YYYYMMDDhhmmssToDateTime YYYYMMDDhhmmssToDateTime64 YYYYMMDDToDate YYYYMMDDToDate32", + dictionary: + "dictGet dictGetAll dictGetChildren dictGetDate dictGetDateOrDefault dictGetDateTime dictGetDateTimeOrDefault dictGetDescendants dictGetFloat32 dictGetFloat32OrDefault dictGetFloat64 dictGetFloat64OrDefault dictGetHierarchy dictGetInt16 dictGetInt16OrDefault dictGetInt32 dictGetInt32OrDefault dictGetInt64 dictGetInt64OrDefault dictGetInt8 dictGetInt8OrDefault dictGetIPv4 dictGetIPv4OrDefault dictGetIPv6 dictGetIPv6OrDefault dictGetKeys dictGetOrDefault dictGetOrNull dictGetString dictGetStringOrDefault dictGetUInt16 dictGetUInt16OrDefault dictGetUInt32 dictGetUInt32OrDefault dictGetUInt64 dictGetUInt64OrDefault dictGetUInt8 dictGetUInt8OrDefault dictGetUUID dictGetUUIDOrDefault dictHas dictIsIn", + encoding: + "aes_decrypt_mysql aes_encrypt_mysql base32Decode base32Encode base58Decode base58Encode base64Decode base64Encode base64URLDecode base64URLEncode bech32Decode bech32Encode bin decodeHTMLComponent decodeURLComponent decodeURLFormComponent decodeXMLComponent decrypt encodeURLComponent encodeURLFormComponent encodeXMLComponent encrypt hex idnaDecode idnaEncode punycodeDecode punycodeEncode sqidDecode sqidEncode tryBase32Decode tryBase58Decode tryBase64Decode tryBase64URLDecode unbin unhex", + geo: "areaCartesian areaSpherical flipCoordinates geoDistance geohashDecode geohashEncode geohashesInBox geoToH3 geoToS2 greatCircleAngle greatCircleDistance h3CellAreaM2 h3CellAreaRads2 h3Distance h3EdgeAngle h3EdgeLengthKm h3EdgeLengthM h3ExactEdgeLengthKm h3ExactEdgeLengthM h3ExactEdgeLengthRads h3GetBaseCell h3GetDestinationIndexFromUnidirectionalEdge h3GetFaces h3GetIndexesFromUnidirectionalEdge h3GetOriginIndexFromUnidirectionalEdge h3GetPentagonIndexes h3GetRes0Indexes h3GetResolution h3GetUnidirectionalEdge h3GetUnidirectionalEdgeBoundary h3GetUnidirectionalEdgesFromHexagon h3HexAreaKm2 h3HexAreaM2 h3HexRing h3IndexesAreNeighbors h3IsPentagon h3IsResClassIII h3IsValid h3kRing h3Line h3NumHexagons h3PointDistKm h3PointDistM h3PointDistRads h3PolygonToCells h3PolygonToCellsWithContainment h3ToCenterChild h3ToChildren h3ToGeo h3ToGeoBoundary h3ToParent h3ToString h3UnidirectionalEdgeIsValid MVTBoundingBox MVTBoundingBoxMercator MVTEncodeGeom perimeterCartesian perimeterSpherical pointInEllipses pointInPolygon polygonAreaCartesian polygonAreaSpherical polygonConvexHullCartesian polygonPerimeterCartesian polygonPerimeterSpherical polygonsDistanceCartesian polygonsDistanceSpherical polygonsEqualsCartesian polygonsIntersectCartesian polygonsIntersectionCartesian polygonsIntersectionSpherical polygonsIntersectSpherical polygonsSymDifferenceCartesian polygonsSymDifferenceSpherical polygonsUnionCartesian polygonsUnionSpherical polygonsWithinCartesian polygonsWithinSpherical readWKB readWKBLineString readWKBMultiLineString readWKBMultiPolygon readWKBPoint readWKBPolygon readWKT readWKTLineString readWKTMultiLineString readWKTMultiPolygon readWKTPoint readWKTPolygon readWKTRing s2CapContains s2CapUnion s2CellsIntersect s2GetNeighbors s2RectAdd s2RectContains s2RectIntersection s2RectUnion s2ToGeo", + hash: "BLAKE3 cityHash64 CRC32 CRC32IEEE CRC64 farmFingerprint64 farmHash64 gccMurmurHash halfMD5 hiveHash icebergHash intHash32 intHash64 isDynamicElementInSharedData javaHash javaHashUTF16LE jumpConsistentHash kafkaMurmurHash keccak256 kostikConsistentHash MD4 MD5 metroHash64 murmurHash2_32 murmurHash2_64 murmurHash3_128 murmurHash3_32 murmurHash3_64 ngramMinHash ngramMinHashArg ngramMinHashArgCaseInsensitive ngramMinHashArgCaseInsensitiveUTF8 ngramMinHashArgUTF8 ngramMinHashCaseInsensitive ngramMinHashCaseInsensitiveUTF8 ngramMinHashUTF8 ngramSimHash ngramSimHashCaseInsensitive ngramSimHashCaseInsensitiveUTF8 ngramSimHashUTF8 normalizedQueryHash normalizedQueryHashKeepNames RIPEMD160 SHA1 SHA224 SHA256 SHA384 SHA512 SHA512_256 shardCount shardNum sipHash128 sipHash128Keyed sipHash128Reference sipHash128ReferenceKeyed sipHash64 sipHash64Keyed sparseGramsHashes sparseGramsHashesUTF8 URLHash wordShingleMinHash wordShingleMinHashArg wordShingleMinHashArgCaseInsensitive wordShingleMinHashArgCaseInsensitiveUTF8 wordShingleMinHashArgUTF8 wordShingleMinHashCaseInsensitive wordShingleMinHashCaseInsensitiveUTF8 wordShingleMinHashUTF8 wordShingleSimHash wordShingleSimHashCaseInsensitive wordShingleSimHashCaseInsensitiveUTF8 wordShingleSimHashUTF8 wyHash64 xxh3 xxh3_128 xxHash32 xxHash64", + ip: "cutIPv6 IPv4CIDRToRange IPv4NumToString IPv4NumToStringClassC IPv4StringToNum IPv4StringToNumOrDefault IPv4StringToNumOrNull IPv4ToIPv6 IPv6CIDRToRange IPv6NumToString IPv6StringToNum IPv6StringToNumOrDefault IPv6StringToNumOrNull isIPAddressInRange isIPv4String isIPv6String MACNumToString MACStringToNum MACStringToOUI", + json: "isValidJSON JSON_EXISTS JSON_QUERY JSON_VALUE JSONAllPaths JSONAllPathsWithTypes JSONAllValues JSONArrayLength JSONDynamicPaths JSONDynamicPathsWithTypes JSONExtract JSONExtractArrayRaw JSONExtractArrayRawCaseInsensitive JSONExtractBool JSONExtractBoolCaseInsensitive JSONExtractCaseInsensitive JSONExtractFloat JSONExtractFloatCaseInsensitive JSONExtractInt JSONExtractIntCaseInsensitive JSONExtractKeys JSONExtractKeysAndValues JSONExtractKeysAndValuesCaseInsensitive JSONExtractKeysAndValuesRaw JSONExtractKeysAndValuesRawCaseInsensitive JSONExtractKeysCaseInsensitive JSONExtractRaw JSONExtractRawCaseInsensitive JSONExtractString JSONExtractStringCaseInsensitive JSONExtractUInt JSONExtractUIntCaseInsensitive JSONHas JSONKey JSONLength JSONMergePatch JSONSharedDataPaths JSONSharedDataPathsWithTypes JSONType prettyPrintJSON simpleJSONExtractBool simpleJSONExtractFloat simpleJSONExtractInt simpleJSONExtractRaw simpleJSONExtractString simpleJSONExtractUInt simpleJSONHas toJSONString", + map: "map mapAdd mapAll mapApply mapConcat mapContainsKey mapContainsKeyLike mapContainsValue mapContainsValueLike mapExists mapExtractKeyLike mapExtractValueLike mapFilter mapFromArrays mapKeys mapPartialReverseSort mapPartialSort mapPopulateSeries mapReverseSort mapSort mapSubtract mapUpdate mapValues", + math: "abs acos acosh asin asinh atan atan2 atanh avg2 cbrt ceil clamp cos cosh cosineDistance cosineDistanceTransposed degrees divide divideDecimal divideOrNull dotProduct dotProductTransposed e erf erfc exp exp10 exp2 factorial floor gcd hypot intDiv intDivOrNull intDivOrZero intExp10 intExp2 L1Distance L1Norm L1Normalize L2Distance L2DistanceTransposed L2Norm L2Normalize L2SquaredDistance L2SquaredNorm lcm LinfDistance LinfNorm LinfNormalize log log10 log1p log2 logTrace LpDistance LpNorm LpNormalize max2 min2 minus modulo moduloLegacy moduloOrNull moduloOrZero multiply multiplyDecimal pi plus positiveModulo positiveModuloOrNull pow proportionsZTest radians round roundAge roundBankers roundDown roundDuration roundToExp2 sigmoid sign sin sinh sqrt tan tanh trunc widthBucket", + nullable: "assumeNotNull coalesce firstNonDefault ifNull isNotNull isNull isNullable isZeroOrNull nullIf", + random: + "generateSnowflakeID generateULID generateUUIDv4 generateUUIDv7 rand rand64 randBernoulli randBinomial randCanonical randChiSquared randConstant randExponential randFisherF randLogNormal randNegativeBinomial randNormal randomFixedString randomHadamardTransform randomPrintableASCII randomString randomStringUTF8 randPoisson randStudentT randUniform", + string: + "alphaTokens appendTrailingCharIfAbsent ascii caseFoldUTF8 char compareSubstrings concat concatAssumeInjective concatWithSeparator concatWithSeparatorAssumeInjective countEqual countMatches countMatchesCaseInsensitive countSubstrings countSubstringsCaseInsensitive countSubstringsCaseInsensitiveUTF8 damerauLevenshteinDistance editDistance editDistanceUTF8 endsWith endsWithCaseInsensitive endsWithCaseInsensitiveUTF8 endsWithUTF8 extract extractAll extractAllGroupsHorizontal extractAllGroupsVertical extractGroups extractKeyValuePairs extractKeyValuePairsWithEscaping extractTextFromHTML firstLine formatQuery formatQueryOrNull formatQuerySingleLine formatQuerySingleLineOrNull formatReadableDecimalSize formatReadableQuantity formatReadableSize formatReadableTimeDelta formatRow formatRowNoNewline hasPhrase hasSubsequence hasSubsequenceCaseInsensitive hasSubsequenceCaseInsensitiveUTF8 hasSubsequenceUTF8 hasSubstr hasToken hasTokenCaseInsensitive hasTokenCaseInsensitiveOrNull hasTokenOrNull ilike initcap initcapUTF8 jaroSimilarity jaroWinklerSimilarity left leftPad leftPadUTF8 leftUTF8 length lengthUTF8 like locate lower lowerUTF8 match multiFuzzyMatchAllIndices multiFuzzyMatchAny multiFuzzyMatchAnyIndex multiMatchAllIndices multiMatchAny multiMatchAnyIndex multiSearchAllPositions multiSearchAllPositionsCaseInsensitive multiSearchAllPositionsCaseInsensitiveUTF8 multiSearchAllPositionsUTF8 multiSearchAny multiSearchAnyCaseInsensitive multiSearchAnyCaseInsensitiveUTF8 multiSearchAnyUTF8 multiSearchFirstIndex multiSearchFirstIndexCaseInsensitive multiSearchFirstIndexCaseInsensitiveUTF8 multiSearchFirstIndexUTF8 multiSearchFirstPosition multiSearchFirstPositionCaseInsensitive multiSearchFirstPositionCaseInsensitiveUTF8 multiSearchFirstPositionUTF8 ngramDistance ngramDistanceCaseInsensitive ngramDistanceCaseInsensitiveUTF8 ngramDistanceUTF8 ngrams ngramSearch ngramSearchCaseInsensitive ngramSearchCaseInsensitiveUTF8 ngramSearchUTF8 normalizeUTF8NFC normalizeUTF8NFD normalizeUTF8NFKC normalizeUTF8NFKCCasefold normalizeUTF8NFKD overlay overlayUTF8 position positionCaseInsensitive positionCaseInsensitiveUTF8 positionUTF8 printf repeat replaceAll replaceOne replaceRegexpAll replaceRegexpOne reverse reverseBySeparator reverseUTF8 right rightPad rightPadUTF8 rightUTF8 soundex space sparseGrams sparseGramsUTF8 splitByChar splitByNonAlpha splitByRegexp splitByString splitByWhitespace startsWith startsWithCaseInsensitive startsWithCaseInsensitiveUTF8 startsWithUTF8 stem stringBytesEntropy stringBytesUniq stringJaccardIndex stringJaccardIndexUTF8 stringToH3 substring substringIndex substringIndexUTF8 substringUTF8 tokens tokensForLikePattern translate translateUTF8 trimBoth trimLeft trimRight upper upperUTF8 visibleWidth", + tuple: + "flattenTuple tuple tupleConcat tupleDivide tupleDivideByNumber tupleElement tupleHammingDistance tupleIntDiv tupleIntDivByNumber tupleIntDivOrZero tupleIntDivOrZeroByNumber tupleMinus tupleModulo tupleModuloByNumber tupleMultiply tupleMultiplyByNumber tupleNames tupleNegate tuplePlus tuplePositiveModuloByNumber tupleToNameValuePairs", + url: "basename cutFragment cutQueryString cutQueryStringAndFragment cutToFirstSignificantSubdomain cutToFirstSignificantSubdomainCustom cutToFirstSignificantSubdomainCustomRFC cutToFirstSignificantSubdomainCustomWithWWW cutToFirstSignificantSubdomainCustomWithWWWRFC cutToFirstSignificantSubdomainRFC cutToFirstSignificantSubdomainWithWWW cutToFirstSignificantSubdomainWithWWWRFC cutURLParameter cutWWW domain domainRFC domainWithoutWWW domainWithoutWWWRFC extractURLParameter extractURLParameterNames extractURLParameters firstSignificantSubdomain firstSignificantSubdomainCustom firstSignificantSubdomainCustomRFC firstSignificantSubdomainRFC FQDN fragment netloc path pathFull port portRFC protocol topLevelDomain topLevelDomainRFC URLHierarchy URLPathHierarchy", + window: "cume_dist dense_rank first_value lag lagInFrame last_value lead leadInFrame nth_value ntile percent_rank rank row_number", + other: + "addressToLine addressToLineWithInlines addressToSymbol addTupleOfIntervals aiClassify aiEmbed aiExtract aiGenerate aiTranslate authenticatedUser bar bitAnd bitCount bitHammingDistance bitNot bitOr bitPositionsToArray bitRotateLeft bitRotateRight bitShiftLeft bitShiftRight bitSlice bitTest bitTestAll bitTestAny bitXor blockNumber blockSerializedSize blockSize buildId byteHammingDistance byteSize byteSwap caseWithExpression catboostEvaluate colorOKLABToSRGB colorOKLCHToSRGB colorSRGBToOKLAB colorSRGBToOKLCH connectionId conv convertCharset countDigits currentDatabase currentProfiles currentQueryID currentRoles currentSchemas currentUser defaultProfiles defaultRoles demangle dequantizeInt8ToBFloat16 detectCharset detectLanguage detectLanguageMixed detectLanguageUnknown detectTonality displayName dumpColumnStructure enabledProfiles enabledRoles errorCodeToName evalMLMethod filesystemAvailable filesystemCapacity filesystemUnreserved finalizeAggregation financialInternalRateOfReturn financialInternalRateOfReturnExtended financialNetPresentValue financialNetPresentValueExtended fuzzBits generateRandomStructure generateSerialID getClientHTTPHeader getMacro getMaxTableNameLengthForDatabase getMergeTreeSetting getOSKernelVersion getServerPort getServerSetting getSetting getSettingOrDefault getSizeOfEnumType getSubcolumn getTypeSerializationStreams globalNullIn globalNullInIgnoreSet globalVariable hasColumnInTable hasThreadFuzzer highlight highlightQuery hilbertDecode hilbertEncode HMAC hostName icebergBucket icebergTruncate identity ifNotFinite ignore indexHint indexOf indexOfAssumeSorted inIgnoreSet initializeAggregation initialQueryID initialQueryStartTime isConstant isDecimalOverflow isFinite isInfinite isMergeTreePartCoveredBy isNaN isPrime isProbablePrime isValidASCII isValidUTF8 joinGet joinGetOrNull lemmatize lgamma localtime lowCardinalityIndices lowCardinalityKeys mergeTreePartInfo midpoint minSampleSizeContinuous minSampleSizeConversion mortonDecode mortonEncode naiveBayesClassifier naturalSortKey negate neighbor nested normalizeQuery normalizeQueryKeepNames notILike notLike notNullIn notNullInIgnoreSet nullIn nullInIgnoreSet numericIndexedVectorAllValueSum numericIndexedVectorBuild numericIndexedVectorCardinality numericIndexedVectorGetValue numericIndexedVectorPointwiseAdd numericIndexedVectorPointwiseDivide numericIndexedVectorPointwiseEqual numericIndexedVectorPointwiseGreater numericIndexedVectorPointwiseGreaterEqual numericIndexedVectorPointwiseLess numericIndexedVectorPointwiseLessEqual numericIndexedVectorPointwiseMultiply numericIndexedVectorPointwiseNotEqual numericIndexedVectorPointwiseSubtract numericIndexedVectorShortDebugString numericIndexedVectorToMap obfuscateQuery obfuscateQueryWithSeed parseReadableSize parseReadableSizeOrNull parseReadableSizeOrZero partitionId quantizeBFloat16ToInt8 queryID queryString queryStringAndFragment range regexpExtract regexpPosition regexpQuoteMeta regionHierarchy regionIn regionToArea regionToCity regionToContinent regionToCountry regionToDistrict regionToName regionToPopulation regionToTopContinent removeDiacriticsUTF8 replicate revision rowNumberInAllBlocks rowNumberInBlock runningAccumulate runningConcurrency runningDifference runningDifferenceStartingWithFirstValue seriesDecomposeSTL seriesOutliersDetectTukey seriesPeriodDetectFFT serverTimezone serverUUID showCertificate sleep sleepEachRow structureToCapnProtoSchema structureToProtobufSchema subDate subtractTupleOfIntervals svg synonyms tcpPort tgamma throwIf tid tokenizeQuery toValidUTF8 transactionID transactionLatestSnapshot transactionOldestSnapshot transform tryDecrypt tryIdnaEncode tryPunycodeDecode uniqThetaIntersect uniqThetaNot uniqThetaUnion uptime UUIDNumToString UUIDStringToNum UUIDToNum UUIDv7ToDateTime validateNestedArraySizes version windowID wkb wkt zookeeperSessionUptime", +}; + +const CLICKHOUSE_ALIASES_BY_CANONICAL: Record = { + dense_rank: ["denseRank"], + alphaTokens: ["splitByAlpha"], + arrayAUCPR: ["arrayPRAUC"], + arrayFlatten: ["flatten"], + arrayJoin: ["unnest"], + arrayRemove: ["array_remove"], + arrayROCAUC: ["arrayAUC"], + arrayStringConcat: ["array_to_string"], + authenticatedUser: ["authUser"], + base64Decode: ["FROM_BASE64"], + base64Encode: ["TO_BASE64"], + byteHammingDistance: ["mismatches"], + caseWithExpression: ["caseWithExpr"], + ceil: ["ceiling"], + concatWithSeparator: ["concat_ws"], + connectionId: ["connection_id"], + cosineDistance: ["distanceCosine"], + cosineDistanceTransposed: ["distanceCosineTransposed"], + currentDatabase: ["DATABASE", "SCHEMA", "current_database"], + currentQueryID: ["current_query_id"], + currentSchemas: ["current_schemas"], + currentUser: ["current_user", "session_user", "user"], + dateDiff: ["DATE_DIFF", "TIMESTAMP_DIFF", "timestampDiff"], + dateTrunc: ["DATE_TRUNC"], + dotProduct: ["scalarProduct"], + dotProductTransposed: ["scalarProductTransposed"], + editDistance: ["levenshteinDistance"], + editDistanceUTF8: ["levenshteinDistanceUTF8"], + extractAllGroupsVertical: ["extractAllGroups"], + extractKeyValuePairs: ["mapFromString", "str_to_map"], + formatDateTime: ["DATE_FORMAT"], + formatReadableSize: ["FORMAT_BYTES"], + FQDN: ["fullHostName"], + fromDaysSinceYearZero: ["FROM_DAYS"], + fromUnixTimestamp: ["FROM_UNIXTIME"], + fromUTCTimestamp: ["from_utc_timestamp"], + hasAllTokens: ["hasAllToken"], + hasAnyTokens: ["hasAnyToken"], + hasPhrase: ["matchPhrase"], + initialQueryID: ["initial_query_id"], + initialQueryStartTime: ["initial_query_start_time"], + IPv4NumToString: ["INET_NTOA"], + IPv4StringToNum: ["INET_ATON"], + IPv6NumToString: ["INET6_NTOA"], + IPv6StringToNum: ["INET6_ATON"], + isValidASCII: ["isASCII"], + JSONArrayLength: ["JSON_ARRAY_LENGTH"], + kostikConsistentHash: ["yandexConsistentHash"], + L1Distance: ["distanceL1"], + L1Norm: ["normL1"], + L1Normalize: ["normalizeL1"], + L2Distance: ["distanceL2"], + L2DistanceTransposed: ["distanceL2Transposed"], + L2Norm: ["normL2"], + L2Normalize: ["normalizeL2"], + L2SquaredDistance: ["distanceL2Squared"], + L2SquaredNorm: ["normL2Squared"], + leftPad: ["lpad"], + length: ["CARDINALITY", "OCTET_LENGTH"], + lengthUTF8: ["CHARACTER_LENGTH", "CHAR_LENGTH"], + LinfDistance: ["distanceLinf"], + LinfNorm: ["normLinf"], + LinfNormalize: ["normalizeLinf"], + log: ["ln"], + lower: ["lcase"], + LpDistance: ["distanceLp"], + LpNorm: ["normLp"], + LpNormalize: ["normalizeLp"], + mapContainsKey: ["mapContains"], + mapFromArrays: ["MAP_FROM_ARRAYS"], + match: ["REGEXP_MATCHES"], + minSampleSizeContinuous: ["minSampleSizeContinous"], + modulo: ["mod"], + moduloOrNull: ["modOrNull"], + multiIf: ["caseWithoutExpr", "caseWithoutExpression"], + MVTEncodeGeom: ["ST_AsMVTGeom"], + naturalSortKey: ["NATURAL_SORT_KEY"], + now: ["current_timestamp", "localtimestamp"], + parseDateTime: ["TO_UNIXTIME"], + parseDateTimeOrNull: ["str_to_date"], + percent_rank: ["percentRank"], + positionCaseInsensitive: ["instr"], + positiveModulo: ["pmod", "positive_modulo"], + positiveModuloOrNull: ["pmodOrNull", "positive_modulo_or_null"], + pow: ["power"], + queryID: ["query_id"], + rand: ["rand32"], + readWKBLineString: ["ST_LineFromWKB"], + readWKBMultiLineString: ["ST_MLineFromWKB"], + readWKBMultiPolygon: ["ST_MPolyFromWKB"], + readWKBPoint: ["ST_PointFromWKB"], + readWKBPolygon: ["ST_PolyFromWKB"], + regexpExtract: ["REGEXP_EXTRACT", "REGEXP_SUBSTR"], + regexpPosition: ["regexpInstr", "regexp_instr"], + removeDiacriticsUTF8: ["removeAccentsUTF8"], + replaceAll: ["replace"], + replaceRegexpAll: ["REGEXP_REPLACE"], + rightPad: ["rpad"], + simpleJSONExtractBool: ["visitParamExtractBool"], + simpleJSONExtractFloat: ["visitParamExtractFloat"], + simpleJSONExtractInt: ["visitParamExtractInt"], + simpleJSONExtractRaw: ["visitParamExtractRaw"], + simpleJSONExtractString: ["visitParamExtractString"], + simpleJSONExtractUInt: ["visitParamExtractUInt"], + simpleJSONHas: ["visitParamHas"], + sqidEncode: ["sqid"], + substring: ["byteSlice", "mid", "substr"], + substringIndex: ["SUBSTRING_INDEX"], + timeSeriesGroupToTags: ["timeSeriesTagsGroupToTags"], + timeSeriesIdToGroup: ["timeSeriesIdToTagsGroup"], + today: ["curdate", "current_date"], + toDayOfMonth: ["DAY", "DAYOFMONTH"], + toDayOfWeek: ["DAYOFWEEK"], + toDayOfYear: ["DAYOFYEAR"], + toDaysSinceYearZero: ["TO_DAYS"], + toHour: ["HOUR"], + toLastDayOfMonth: ["LAST_DAY"], + toMicrosecond: ["MICROSECOND"], + toMillisecond: ["MILLISECOND"], + toMinute: ["MINUTE"], + toMonth: ["MONTH"], + toNanosecond: ["NANOSECOND"], + toQuarter: ["QUARTER"], + toSecond: ["SECOND"], + toStartOfFiveMinutes: ["toStartOfFiveMinute"], + toStartOfInterval: ["date_bin", "time_bucket"], + toUTCTimestamp: ["to_utc_timestamp"], + toWeek: ["week"], + toYear: ["YEAR"], + toYearWeek: ["yearweek"], + trimBoth: ["trim"], + trimLeft: ["ltrim"], + trimRight: ["rtrim"], + trunc: ["truncate"], + tupleMinus: ["vectorDifference"], + tuplePlus: ["vectorSum"], + upper: ["ucase"], + UTCTimestamp: ["UTC_timestamp"], + widthBucket: ["width_bucket"], +}; + +const sig = (...parameterGroups: string[][]): ClickHouseFunctionSignature => ({ parameterGroups }); + +const SIGNATURE_OVERRIDES: Record = { + arrayElement: [sig(["array", "index"])], + arrayFilter: [sig(["lambda", "array", "...arrays"])], + arrayJoin: [sig(["array"])], + arrayMap: [sig(["lambda", "array", "...arrays"])], + cityHash64: [sig(["argument", "...arguments"])], + concat: [sig(["value", "...values"])], + cume_dist: [sig([])], + dense_rank: [sig([])], + dictGet: [sig(["dictionary", "attribute", "key"])], + formatDateTime: [sig(["value", "format"]), sig(["value", "format", "time_zone"])], + geoDistance: [sig(["longitude1", "latitude1", "longitude2", "latitude2"])], + JSONExtract: [sig(["json", "path", "...paths", "return_type"])], + JSONExtractString: [sig(["json", "path", "...paths"])], + lag: [sig(["value"]), sig(["value", "offset"]), sig(["value", "offset", "default"])], + lagInFrame: [sig(["value"]), sig(["value", "offset"]), sig(["value", "offset", "default"])], + lead: [sig(["value"]), sig(["value", "offset"]), sig(["value", "offset", "default"])], + leadInFrame: [sig(["value"]), sig(["value", "offset"]), sig(["value", "offset", "default"])], + length: [sig(["value"])], + lower: [sig(["string"])], + map: [sig(["key", "value", "...pairs"])], + now: [sig([]), sig(["time_zone"])], + ntile: [sig(["buckets"])], + percent_rank: [sig([])], + rank: [sig([])], + row_number: [sig([])], + substring: [sig(["string", "offset"]), sig(["string", "offset", "length"])], + toDate: [sig(["value"]), sig(["value", "time_zone"])], + toDateTime: [sig(["value"]), sig(["value", "time_zone"])], + toStartOfDay: [sig(["value"]), sig(["value", "time_zone"])], + toStartOfInterval: [sig(["value", "INTERVAL x unit"]), sig(["value", "INTERVAL x unit", "time_zone"]), sig(["value", "INTERVAL x unit", "origin", "time_zone?"])], + tuple: [sig(["value", "...values"])], + upper: [sig(["string"])], + URLHierarchy: [sig(["url"])], +}; + +const ZERO_ARGUMENT_FUNCTIONS = new Set([ + "buildId", + "currentDatabase", + "currentQueryID", + "currentUser", + "e", + "generateUUIDv4", + "generateUUIDv7", + "hostName", + "now", + "pi", + "rand", + "rand64", + "revision", + "row_number", + "serverTimezone", + "serverUUID", + "today", + "timezone", + "uptime", + "version", + "yesterday", +]); + +export const CLICKHOUSE_WINDOW_FUNCTION_NAMES = new Set(["cume_dist", "dense_rank", "denseRank", "first_value", "lag", "lagInFrame", "last_value", "lead", "leadInFrame", "nth_value", "ntile", "percent_rank", "percentRank", "rank", "row_number"]); + +function definition(name: string, category: ClickHouseFunctionCategory): ClickHouseFunctionDefinition { + const kind = category === "window" ? "window" : "regular"; + const signatures = Object.prototype.hasOwnProperty.call(SIGNATURE_OVERRIDES, name) ? SIGNATURE_OVERRIDES[name] : [sig(ZERO_ARGUMENT_FUNCTIONS.has(name) ? [] : ["argument", "...arguments?"])]; + const aliases = Object.prototype.hasOwnProperty.call(CLICKHOUSE_ALIASES_BY_CANONICAL, name) ? CLICKHOUSE_ALIASES_BY_CANONICAL[name] : undefined; + return { + name, + kind, + category, + signatures, + aliases, + }; +} + +export const CLICKHOUSE_REGULAR_FUNCTIONS: ClickHouseFunctionDefinition[] = Object.entries(CLICKHOUSE_REGULAR_NAMES_BY_CATEGORY).flatMap(([category, names]) => + (names ?? "") + .split(" ") + .filter(Boolean) + .map((name) => definition(name, category as ClickHouseFunctionCategory)), +); + +export const CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST = [ + { category: "array", minimumCount: 80 }, + { category: "bitmap", minimumCount: 22 }, + { category: "comparison", minimumCount: 46 }, + { category: "conversion", minimumCount: 139 }, + { category: "date-time", minimumCount: 212 }, + { category: "dictionary", minimumCount: 42 }, + { category: "encoding", minimumCount: 35 }, + { category: "geo", minimumCount: 100 }, + { category: "hash", minimumCount: 77 }, + { category: "ip", minimumCount: 19 }, + { category: "json", minimumCount: 48 }, + { category: "map", minimumCount: 23 }, + { category: "math", minimumCount: 89 }, + { category: "nullable", minimumCount: 9 }, + { category: "random", minimumCount: 24 }, + { category: "string", minimumCount: 156 }, + { category: "tuple", minimumCount: 21 }, + { category: "url", minimumCount: 37 }, + { category: "window", minimumCount: 13 }, + { category: "other", minimumCount: 236 }, +] as const; diff --git a/apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts b/apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts new file mode 100644 index 000000000..4e100712c --- /dev/null +++ b/apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts @@ -0,0 +1,95 @@ +import type { ClickHouseFunctionDefinition } from "./functionTypes"; + +/** Static name snapshot from ClickHouse Playground system.table_functions on 2026-07-31. */ +export const CLICKHOUSE_TABLE_FUNCTIONS: ClickHouseFunctionDefinition[] = [ + { name: "SQLStandardValues", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "arrowFlight", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "azureBlobStorage", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "azureBlobStorageCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "cluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "clusterAllReplicas", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "cosn", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "deltaLake", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "deltaLakeAzure", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "deltaLakeAzureCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "deltaLakeCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "deltaLakeLocal", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "deltaLakeS3", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "deltaLakeS3Cluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "dictionary", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "executable", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "file", kind: "table", category: "table", signatures: [{ parameterGroups: [["path", "format?", "structure?", "compression?"]] }] }, + { name: "fileCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "filesystem", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "format", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "fuzzJSON", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "fuzzQuery", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "gcs", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "generateRandom", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "generateSeries", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "generate_series", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "hdfs", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "hdfsCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "hive", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "hudi", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "hudiCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "iceberg", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergAzure", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergAzureCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergHDFS", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergHDFSCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergLocal", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergLocalCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergS3", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "icebergS3Cluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "input", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "jdbc", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "loop", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "merge", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "mergeTreeAnalyzeIndexes", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "mergeTreeAnalyzeIndexesUUID", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "mergeTreeIndex", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "mergeTreeProjection", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "mergeTreeTextIndex", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "mongodb", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "mysql", kind: "table", category: "table", signatures: [{ parameterGroups: [["address", "database", "table", "user", "password"]] }] }, + { name: "null", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "numbers", kind: "table", category: "table", signatures: [{ parameterGroups: [["count"]] }, { parameterGroups: [["offset", "count"]] }, { parameterGroups: [["offset", "count", "step"]] }] }, + { name: "numbers_mt", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "odbc", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "oss", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimon", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonAzure", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonAzureCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonHDFS", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonHDFSCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonLocal", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonS3", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "paimonS3Cluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "postgresql", kind: "table", category: "table", signatures: [{ parameterGroups: [["address", "database", "table", "user", "password", "schema?"]] }] }, + { name: "primes", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "prometheusQuery", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "prometheusQueryRange", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "redis", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "remote", kind: "table", category: "table", signatures: [{ parameterGroups: [["addresses", "database", "table", "user?", "password?"]] }] }, + { name: "remoteSecure", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "s3", kind: "table", category: "table", signatures: [{ parameterGroups: [["url", "format?", "structure?", "compression?"]] }] }, + { name: "s3Cluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "sqlite", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "timeSeriesData", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "timeSeriesMetrics", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "timeSeriesSamples", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "timeSeriesSelector", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "timeSeriesTags", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "url", kind: "table", category: "table", signatures: [{ parameterGroups: [["url", "format", "structure?", "headers?"]] }] }, + { name: "urlCluster", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "values", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "view", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "viewExplain", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "viewIfPermitted", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "ytsaurus", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "zeros", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, + { name: "zeros_mt", kind: "table", category: "table", signatures: [{ parameterGroups: [["argument", "...arguments?"]] }] }, +]; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 5fc56cafd..996f6f72d 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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, 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, 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; diff --git a/docs/superpowers/plans/2026-07-30-clickhouse-function-completion.md b/docs/superpowers/plans/2026-07-30-clickhouse-function-completion.md new file mode 100644 index 000000000..6bd7dc614 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-clickhouse-function-completion.md @@ -0,0 +1,896 @@ +# ClickHouse Function Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add broad, static ClickHouse function completion with canonical casing, overload-aware and parametric signature help, table functions, and valid aggregate-combinator variants. + +**Architecture:** Keep ClickHouse definitions and deterministic combinator logic in `apps/desktop/src/lib/sql/clickhouse/`. Adapt that registry into the existing generic SQL completion provider without changing other database inventories. Extend signature help to return overloads and parameter groups, then render that richer result in the existing CodeMirror tooltip. + +**Tech Stack:** TypeScript 6, Vue 3, CodeMirror 6, Vitest 4, pnpm 10. + +--- + +## File Map + +**Create** + +- `apps/desktop/src/lib/sql/clickhouse/functionTypes.ts` — shared ClickHouse function, signature, category, status, and registry-query types. +- `apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts` — scalar and window-function inventory plus category manifest. +- `apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts` — ordinary and parametric aggregate inventory. +- `apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts` — functions valid as table sources. +- `apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts` — legal suffix transitions and signature transformations. +- `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts` — validation, canonical lookup, prefix search, and lazy combinator generation. +- `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts` — registry integrity and lookup coverage. +- `apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts` — combinator ordering and signature transformations. +- `apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts` — overload and parametric-call parsing. +- `apps/desktop/src/lib/editor/sqlSignatureTooltip.ts` — render overload-aware signature help without coupling registry logic to Vue. +- `apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts` — exercise the real tooltip DOM in happy-dom. + +**Modify** + +- `apps/desktop/src/lib/sql/sqlCompletion.ts` — ClickHouse registry adapter, table-function context, canonical apply text, and multi-signature resolver. +- `apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts` — ClickHouse completion, table-function, isolation, and regression cases. +- `apps/desktop/src/components/editor/QueryEditor.vue` — render all overloads and active parameters. + +**Reference** + +- `docs/superpowers/specs/2026-07-30-clickhouse-function-completion-design.md` +- `apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts` — existing dialect reconfiguration regression to keep running. +- +- + +## Task 1: Introduce the ClickHouse Function Model and Validated Registry + +**Files:** + +- Create: `apps/desktop/src/lib/sql/clickhouse/functionTypes.ts` +- Create: `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts` +- Create: `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts` + +- [ ] **Step 1: Write failing registry-validation tests** + +Create `functionRegistry.spec.ts` with real definitions covering canonical lookup, overload preservation, duplicate detection, and invalid preferred indexes: + +```ts +import { describe, expect, it } from "vitest"; +import { createClickHouseFunctionRegistry } from "@/lib/sql/clickhouse/functionRegistry"; +import type { ClickHouseFunctionDefinition } from "@/lib/sql/clickhouse/functionTypes"; + +const toStartOfDay: ClickHouseFunctionDefinition = { + name: "toStartOfDay", + kind: "regular", + category: "date-time", + signatures: [{ parameterGroups: [["value", "time_zone?"]], returnType: "DateTime" }], + aliases: ["startOfDay"], +}; + +describe("ClickHouse function registry", () => { + it("looks up canonical names case-insensitively and preserves overloads", () => { + const registry = createClickHouseFunctionRegistry([toStartOfDay]); + expect(registry.get("TOSTARTOFDAY")).toEqual(toStartOfDay); + expect(registry.search("tostart", 20)).toEqual([toStartOfDay]); + expect(registry.search("startof", 20)).toEqual([toStartOfDay]); + }); + + it("rejects duplicate canonical names case-insensitively", () => { + expect(() => createClickHouseFunctionRegistry([toStartOfDay, { ...toStartOfDay, name: "TOSTARTOFDAY" }])).toThrow(/duplicate/i); + }); + + it("rejects an invalid preferred signature index", () => { + expect(() => createClickHouseFunctionRegistry([{ ...toStartOfDay, preferredSignature: 2 }])).toThrow(/preferred signature/i); + }); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm the RED state** + +Run: + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts +``` + +Expected: FAIL because `functionTypes.ts` and `functionRegistry.ts` do not exist. + +- [ ] **Step 3: Add the function types** + +Create `functionTypes.ts`: + +```ts +export type ClickHouseFunctionKind = "regular" | "aggregate" | "window" | "table"; +export type ClickHouseFunctionStatus = "stable" | "experimental" | "deprecated"; + +export type ClickHouseFunctionCategory = + | "aggregate" + | "array" + | "bitmap" + | "comparison" + | "conversion" + | "date-time" + | "dictionary" + | "encoding" + | "geo" + | "hash" + | "ip" + | "json" + | "map" + | "math" + | "nullable" + | "random" + | "string" + | "table" + | "tuple" + | "url" + | "window" + | "other"; + +export interface ClickHouseFunctionSignature { + parameterGroups: string[][]; + returnType?: string; +} + +export interface ClickHouseFunctionDefinition { + name: string; + kind: ClickHouseFunctionKind; + category: ClickHouseFunctionCategory; + signatures: ClickHouseFunctionSignature[]; + description?: string; + preferredSignature?: number; + status?: ClickHouseFunctionStatus; + aliases?: string[]; + combinators?: boolean; + generated?: boolean; +} + +export interface ClickHouseFunctionRegistry { + get(name: string): ClickHouseFunctionDefinition | undefined; + search(prefix: string, limit: number, kind?: ClickHouseFunctionKind): ClickHouseFunctionDefinition[]; + all(): readonly ClickHouseFunctionDefinition[]; +} +``` + +- [ ] **Step 4: Implement strict construction and deterministic prefix search** + +Create `functionRegistry.ts` with a private lowercase index. Validate non-empty names, non-empty signatures, non-empty parameter groups, preferred indexes, canonical collisions, and alias collisions: + +```ts +import type { ClickHouseFunctionDefinition, ClickHouseFunctionKind, ClickHouseFunctionRegistry } from "./functionTypes"; + +function definitionKeys(definition: ClickHouseFunctionDefinition): string[] { + return [definition.name, ...(definition.aliases ?? [])].map((name) => name.toLowerCase()); +} + +function validateDefinition(definition: ClickHouseFunctionDefinition): void { + if (!definition.name.trim()) throw new Error("ClickHouse function name must not be empty"); + if (definition.signatures.length === 0) throw new Error(`ClickHouse function ${definition.name} must define a signature`); + if (definition.signatures.some((signature) => signature.parameterGroups.length === 0)) { + throw new Error(`ClickHouse function ${definition.name} must define a parameter group`); + } + const preferred = definition.preferredSignature ?? 0; + if (preferred < 0 || preferred >= definition.signatures.length) { + throw new Error(`ClickHouse function ${definition.name} has an invalid preferred signature`); + } +} + +export function createClickHouseFunctionRegistry(definitions: readonly ClickHouseFunctionDefinition[]): ClickHouseFunctionRegistry { + const byKey = new Map(); + for (const definition of definitions) { + validateDefinition(definition); + for (const key of definitionKeys(definition)) { + if (byKey.has(key)) throw new Error(`Duplicate ClickHouse function or alias: ${key}`); + byKey.set(key, definition); + } + } + const ordered = [...definitions].sort((left, right) => left.name.localeCompare(right.name)); + return { + get: (name) => byKey.get(name.toLowerCase()), + search: (prefix, limit, kind?: ClickHouseFunctionKind) => { + const normalized = prefix.toLowerCase(); + return ordered.filter((definition) => (!kind || definition.kind === kind) && definitionKeys(definition).some((key) => key.startsWith(normalized))).slice(0, limit); + }, + all: () => ordered, + }; +} +``` + +- [ ] **Step 5: Run the focused test and confirm GREEN** + +Run the same Vitest command. Expected: 3 tests PASS. + +- [ ] **Step 6: Commit the registry foundation** + +```bash +git add apps/desktop/src/lib/sql/clickhouse/functionTypes.ts apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts +git commit -m "feat(sql): add ClickHouse function registry" +``` + +## Task 2: Add the Regular, Window, and Table Function Inventories + +**Files:** + +- Create: `apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts` +- Create: `apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts` +- Modify: `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts` +- Modify: `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts` + +- [ ] **Step 1: Add failing inventory-integrity and representative-coverage tests** + +Extend `functionRegistry.spec.ts`: + +```ts +import { CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST, CLICKHOUSE_REGULAR_FUNCTIONS } from "@/lib/sql/clickhouse/regularFunctions"; +import { CLICKHOUSE_TABLE_FUNCTIONS } from "@/lib/sql/clickhouse/tableFunctions"; +import { CLICKHOUSE_FUNCTION_REGISTRY } from "@/lib/sql/clickhouse/functionRegistry"; + +it("keeps the checked-in category manifest and inventory counts aligned", () => { + for (const entry of CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST) { + expect(CLICKHOUSE_REGULAR_FUNCTIONS.filter((definition) => definition.category === entry.category)).toHaveLength(entry.minimumCount); + } +}); + +it.each([ + ["arrayMap", "array"], + ["toStartOfDay", "date-time"], + ["JSONExtractString", "json"], + ["cityHash64", "hash"], + ["URLHierarchy", "url"], + ["lagInFrame", "window"], +] as const)("contains %s with canonical casing and category %s", (name, category) => { + expect(CLICKHOUSE_FUNCTION_REGISTRY.get(name)).toMatchObject({ name, category }); +}); + +it.each(["numbers", "file", "url", "s3", "remote", "postgresql", "mysql"] as const)("contains the %s table function", (name) => { + expect(CLICKHOUSE_TABLE_FUNCTIONS.some((definition) => definition.name === name && definition.kind === "table")).toBe(true); +}); +``` + +- [ ] **Step 2: Run the registry test and verify the missing-inventory failure** + +Expected: FAIL because the inventory modules and exported default registry do not exist. + +- [ ] **Step 3: Add regular-function helpers and the first documented categories** + +In `regularFunctions.ts`, use a small typed helper so data remains compact: + +```ts +import type { ClickHouseFunctionCategory, ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes"; + +const regular = ( + name: string, + category: ClickHouseFunctionCategory, + signatures: ClickHouseFunctionSignature[], + options: Partial = {}, +): ClickHouseFunctionDefinition => ({ name, kind: "regular", category, signatures, ...options }); + +export const CLICKHOUSE_REGULAR_FUNCTIONS: ClickHouseFunctionDefinition[] = [ + regular("arrayMap", "array", [{ parameterGroups: [["lambda", "array", "...arrays"]], returnType: "Array" }]), + regular("toStartOfDay", "date-time", [ + { parameterGroups: [["value"]], returnType: "DateTime" }, + { parameterGroups: [["value", "time_zone"]], returnType: "DateTime" }, + ]), + regular("JSONExtractString", "json", [{ parameterGroups: [["json", "path", "...paths"]], returnType: "String" }]), + regular("cityHash64", "hash", [{ parameterGroups: [["argument", "...arguments"]], returnType: "UInt64" }]), + regular("URLHierarchy", "url", [{ parameterGroups: [["url"]], returnType: "Array(String)" }]), + { name: "lagInFrame", kind: "window", category: "window", signatures: [{ parameterGroups: [["value", "offset?", "default?"]] }] }, +]; +``` + +Continue in category-sized edits using the official 2026-07-30 documentation snapshot. Each definition must include every documented overload needed to distinguish arity or parameter groups. Cover the manifest categories declared in `functionTypes.ts`; omit a category from the manifest only when ClickHouse has no public function page for it. + +- [ ] **Step 4: Add a checked-in manifest after each category batch** + +Append a manifest whose counts exactly equal the checked-in arrays: + +```ts +export const CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST = [ + { category: "array", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "array").length }, + { category: "date-time", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "date-time").length }, + { category: "json", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "json").length }, + { category: "hash", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "hash").length }, + { category: "url", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "url").length }, + { category: "window", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "window").length }, +] as const; +``` + +Expand this literal as each documented category is added. Do not derive the manifest at runtime; checked-in numeric literals are the reviewable deletion guard. Replace the `.length` expressions above with those final numeric literals before making the test green. + +- [ ] **Step 5: Add table-function definitions** + +Create `tableFunctions.ts`: + +```ts +import type { ClickHouseFunctionDefinition } from "./functionTypes"; + +export const CLICKHOUSE_TABLE_FUNCTIONS: ClickHouseFunctionDefinition[] = [ + { + name: "numbers", + kind: "table", + category: "table", + signatures: [ + { parameterGroups: [["count"]] }, + { parameterGroups: [["offset", "count"]] }, + { parameterGroups: [["offset", "count", "step"]] }, + ], + }, + { name: "file", kind: "table", category: "table", signatures: [{ parameterGroups: [["path", "format?", "structure?", "compression?"]] }] }, + { name: "url", kind: "table", category: "table", signatures: [{ parameterGroups: [["url", "format", "structure?", "headers?"]] }] }, + { name: "s3", kind: "table", category: "table", signatures: [{ parameterGroups: [["url", "format?", "structure?", "compression?"]] }] }, + { name: "remote", kind: "table", category: "table", signatures: [{ parameterGroups: [["addresses", "database", "table", "user?", "password?"]] }] }, + { name: "postgresql", kind: "table", category: "table", signatures: [{ parameterGroups: [["address", "database", "table", "user", "password", "schema?"]] }] }, + { name: "mysql", kind: "table", category: "table", signatures: [{ parameterGroups: [["address", "database", "table", "user", "password"]] }] }, +]; +``` + +Add all other documented public table functions from the same snapshot, retaining lowercase canonical names where ClickHouse documents them that way. + +- [ ] **Step 6: Export the complete direct-function registry** + +In `functionRegistry.ts`: + +```ts +import { CLICKHOUSE_REGULAR_FUNCTIONS } from "./regularFunctions"; +import { CLICKHOUSE_TABLE_FUNCTIONS } from "./tableFunctions"; + +export const CLICKHOUSE_FUNCTION_REGISTRY = createClickHouseFunctionRegistry([ + ...CLICKHOUSE_REGULAR_FUNCTIONS, + ...CLICKHOUSE_TABLE_FUNCTIONS, +]); +``` + +- [ ] **Step 7: Run formatting and the focused registry tests after every category batch** + +```bash +pnpm exec oxfmt apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts +``` + +Expected: all registry tests PASS and manifest counts remain literal, non-zero floors. + +- [ ] **Step 8: Commit the regular and table inventories** + +```bash +git add apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts +git commit -m "feat(sql): add ClickHouse regular function inventory" +``` + +## Task 3: Add Aggregate Functions and Lazy Combinators + +**Files:** + +- Create: `apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts` +- Create: `apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts` +- Create: `apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts` +- Modify: `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts` +- Modify: `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts` + +- [ ] **Step 1: Write failing aggregate and combinator tests** + +Create `aggregateCombinators.spec.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { generateAggregateCombinatorCandidates } from "@/lib/sql/clickhouse/aggregateCombinators"; +import { CLICKHOUSE_FUNCTION_REGISTRY } from "@/lib/sql/clickhouse/functionRegistry"; + +describe("ClickHouse aggregate combinators", () => { + it("generates If with an appended condition argument", () => { + const sumIf = generateAggregateCombinatorCandidates("sumIf", 20).find((item) => item.name === "sumIf"); + expect(sumIf?.signatures[0].parameterGroups).toEqual([["value", "condition"]]); + }); + + it("allows Array before If and rejects the reverse order", () => { + expect(generateAggregateCombinatorCandidates("uniqArrayIf", 20).some((item) => item.name === "uniqArrayIf")).toBe(true); + expect(generateAggregateCombinatorCandidates("uniqIfArray", 20).some((item) => item.name === "uniqIfArray")).toBe(false); + }); + + it("preserves parametric aggregate groups for State", () => { + const state = generateAggregateCombinatorCandidates("quantilesTDigestState", 20).find((item) => item.name === "quantilesTDigestState"); + expect(state?.signatures[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]); + }); + + it("bounds generated results", () => { + expect(generateAggregateCombinatorCandidates("", 7)).toHaveLength(7); + }); +}); + +it("contains ordinary and parametric aggregate definitions", () => { + expect(CLICKHOUSE_FUNCTION_REGISTRY.get("uniqExact")).toMatchObject({ kind: "aggregate" }); + expect(CLICKHOUSE_FUNCTION_REGISTRY.get("quantilesTDigest")?.signatures[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]); +}); +``` + +- [ ] **Step 2: Run both ClickHouse test files and confirm RED** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts +``` + +Expected: FAIL because aggregate modules and generated candidates do not exist. + +- [ ] **Step 3: Add the aggregate inventory** + +Create `aggregateFunctions.ts` with a compact helper and explicit parameter groups: + +```ts +import type { ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes"; + +const aggregate = ( + name: string, + signatures: ClickHouseFunctionSignature[], + options: Partial = {}, +): ClickHouseFunctionDefinition => ({ + name, + kind: "aggregate", + category: "aggregate", + signatures, + combinators: true, + ...options, +}); + +export const CLICKHOUSE_AGGREGATE_FUNCTIONS: ClickHouseFunctionDefinition[] = [ + aggregate("count", [{ parameterGroups: [[]] }, { parameterGroups: [["expression"]] }]), + aggregate("sum", [{ parameterGroups: [["value"]] }]), + aggregate("uniq", [{ parameterGroups: [["expression", "...expressions"]] }]), + aggregate("uniqExact", [{ parameterGroups: [["expression", "...expressions"]] }]), + aggregate("quantilesTDigest", [{ parameterGroups: [["level", "...levels"], ["expression"]] }]), +]; +``` + +Add every documented ordinary and parametric aggregate from the snapshot. Mark aggregates that reject generic combinators with `combinators: false`; do not invent signatures for undocumented combinations. + +- [ ] **Step 4: Implement ordered combinator rules** + +In `aggregateCombinators.ts`, model suffix transitions rather than arbitrary permutations: + +```ts +import { CLICKHOUSE_AGGREGATE_FUNCTIONS } from "./aggregateFunctions"; +import type { ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes"; + +type CombinatorName = "Array" | "Map" | "ForEach" | "Distinct" | "If" | "OrDefault" | "OrNull" | "Resample" | "SimpleState" | "State" | "Merge" | "MergeState"; + +const ORDER: readonly CombinatorName[] = ["Array", "Map", "ForEach", "Distinct", "If", "OrDefault", "OrNull", "Resample", "SimpleState", "State", "Merge", "MergeState"]; + +function applyIf(signature: ClickHouseFunctionSignature): ClickHouseFunctionSignature { + const groups = signature.parameterGroups.map((group) => [...group]); + const target = groups.length - 1; + groups[target] = [...groups[target], "condition"]; + return { ...signature, parameterGroups: groups }; +} +``` + +Add one transformer per documented combinator. Enforce `Array` before `If`, make terminal state/merge combinators stop further suffix expansion where required, and reject cycles. `generateAggregateCombinatorCandidates(prefix, limit)` must traverse only prefixes that can still match the typed text and stop at `limit`. + +- [ ] **Step 5: Merge direct aggregates and lazy generated results** + +Add direct aggregate definitions to `CLICKHOUSE_FUNCTION_REGISTRY`. Export: + +```ts +export function searchClickHouseFunctions(prefix: string, limit: number, kind?: ClickHouseFunctionKind): ClickHouseFunctionDefinition[] { + const direct = CLICKHOUSE_FUNCTION_REGISTRY.search(prefix, limit, kind); + if (kind && kind !== "aggregate") return direct; + const generated = generateAggregateCombinatorCandidates(prefix, Math.max(0, limit - direct.length)); + return [...direct, ...generated].slice(0, limit); +} +``` + +- [ ] **Step 6: Run tests and confirm GREEN** + +Run the two focused files. Expected: all registry and combinator tests PASS. + +- [ ] **Step 7: Commit aggregate support** + +```bash +git add apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts +git commit -m "feat(sql): add ClickHouse aggregate combinators" +``` + +## Task 4: Integrate ClickHouse Functions into SQL Completion + +**Files:** + +- Modify: `apps/desktop/src/lib/sql/sqlCompletion.ts` +- Modify: `apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts` + +- [ ] **Step 1: Add failing completion tests** + +Extend `sqlCompletion.context.spec.ts`: + +```ts +it("suggests ClickHouse functions with canonical casing and preferred placeholders", () => { + const sql = "SELECT tostart"; + const items = buildSqlCompletionItems(sql, sql.length, { databaseType: "clickhouse", tables: [], columnsByTable: new Map() }); + expect(items.find((item) => item.label === "toStartOfDay")).toMatchObject({ type: "function", apply: "toStartOfDay(${value})" }); +}); + +it("does not leak ClickHouse-only functions to MySQL", () => { + const sql = "SELECT tostart"; + const items = buildSqlCompletionItems(sql, sql.length, { databaseType: "mysql", tables: [], columnsByTable: new Map() }); + expect(items.some((item) => item.label === "toStartOfDay")).toBe(false); +}); + +it("suggests only ClickHouse table functions alongside tables after FROM", () => { + const sql = "SELECT * FROM num"; + const items = buildSqlCompletionItems(sql, sql.length, { + databaseType: "clickhouse", + tables: [{ name: "number_events", type: "table" }], + columnsByTable: new Map(), + }); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "numbers", type: "function" }), expect.objectContaining({ label: "number_events", type: "table" })])); + expect(items.some((item) => item.label === "toStartOfDay")).toBe(false); +}); +``` + +Add a cursor-before-existing-parenthesis case: + +```ts +it("does not insert a duplicate opening parenthesis before an existing call", () => { + const sql = "SELECT toStart()"; + const cursor = "SELECT toStart".length; + const items = buildSqlCompletionItems(sql, cursor, { databaseType: "clickhouse", tables: [], columnsByTable: new Map() }); + expect(items.find((item) => item.label === "toStartOfDay")?.apply).toBe("toStartOfDay"); +}); +``` + +- [ ] **Step 2: Run the context test and confirm RED** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +``` + +Expected: ClickHouse-specific labels are absent. + +- [ ] **Step 3: Add cursor lookahead to completion context** + +Add `openingParenAfterCursor: boolean` to `SqlCompletionContext`, populated by `/^\s*\(/.test(sql.slice(cursor))`. Preserve it in semantic-context merging. + +- [ ] **Step 4: Adapt registry definitions to completion items** + +Import `searchClickHouseFunctions`. Add helpers: + +```ts +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}`; +} +``` + +In `buildFunctionSnippetItems`, use the ClickHouse registry when `databaseType === "clickhouse"`; retain the existing map path for all other database types. Set `info` to `definition.description` when present. Apply a negative boost to experimental/deprecated definitions and a smaller negative boost to generated variants. + +- [ ] **Step 5: Add table-function completion only in table contexts** + +Inside the `context.suggestTables` branch, append `kind === "table"` results for ClickHouse. Do not relax `exclusiveTableSuggestions` for scalar or aggregate functions. + +- [ ] **Step 6: Run ClickHouse and existing database completion tests** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts +``` + +Expected: ClickHouse tests PASS; existing MySQL and snippet tests remain PASS. + +- [ ] **Step 7: Commit completion integration** + +```bash +git add apps/desktop/src/lib/sql/sqlCompletion.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +git commit -m "feat(sql): complete ClickHouse functions" +``` + +## Task 5: Add Overload-Aware and Parametric Signature Resolution + +**Files:** + +- Modify: `apps/desktop/src/lib/sql/sqlCompletion.ts` +- Create: `apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts` + +- [ ] **Step 1: Write failing ordinary and parametric signature tests** + +Create `sqlCompletion.signature.spec.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { getSqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion"; + +describe("ClickHouse signature help", () => { + it("returns every overload and highlights the active ordinary parameter", () => { + const sql = "SELECT toStartOfInterval(ts, "; + const help = getSqlFunctionSignatureHelp(sql, sql.length, "clickhouse"); + expect(help?.name).toBe("toStartOfInterval"); + expect(help?.overloads.length).toBeGreaterThan(1); + expect(help?.overloads[0].activeGroup).toBe(0); + expect(help?.overloads[0].activeParameter).toBe(1); + }); + + it("resolves the second parameter group of a parametric aggregate", () => { + const sql = "SELECT quantilesTDigest(0.5, 0.9)(value"; + const help = getSqlFunctionSignatureHelp(sql, sql.length, "clickhouse"); + expect(help?.name).toBe("quantilesTDigest"); + expect(help?.overloads[0]).toMatchObject({ activeGroup: 1, activeParameter: 0 }); + expect(help?.overloads[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]); + }); + + it("keeps MySQL signature help as one overload and one parameter group", () => { + const sql = "SELECT DATE_ADD(created_at, "; + const help = getSqlFunctionSignatureHelp(sql, sql.length, "mysql"); + expect(help?.overloads).toHaveLength(1); + expect(help?.overloads[0].parameterGroups).toEqual([["date", "INTERVAL expr unit"]]); + }); +}); +``` + +- [ ] **Step 2: Run the signature test and confirm RED** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts +``` + +Expected: FAIL because the returned model has no `overloads`. + +- [ ] **Step 3: Replace the public signature-help shape** + +In `sqlCompletion.ts`: + +```ts +export interface SqlFunctionSignatureHelpOverload { + signature: string; + parameterGroups: string[][]; + activeGroup: number; + activeParameter: number; +} + +export interface SqlFunctionSignatureHelp { + name: string; + overloads: SqlFunctionSignatureHelpOverload[]; + activeOverload: number; +} +``` + +- [ ] **Step 4: Resolve the owning call and active group** + +Replace the flat `findActiveFunctionOpenParen` path with a helper returning: + +```ts +interface ActiveFunctionCall { + name: string; + activeGroup: number; + groupText: string; +} +``` + +For a normal unmatched `(`, read the identifier directly before it and return group `0`. If the identifier position contains `)`, find that balanced group's opening parenthesis, read the identifier before the first group, and return group `1`. Count commas with the existing quote- and nesting-aware `countTopLevelCommas`. + +- [ ] **Step 5: Convert old database maps into one-overload definitions** + +When the database is not ClickHouse, wrap the existing `string[]` as `parameterGroups: [parameters]`. For ClickHouse, find an exact case-insensitive match across `searchClickHouseFunctions(name, 50)`, so lazily generated combinator names and aliases resolve as well as direct registry entries. Format all parameter groups into each overload's `signature`, while using the function spelling from the SQL text as the tooltip name. + +Rank overloads that can accept the observed parameter index first; keep source order as the stable tiebreaker. Set `activeOverload` to `0`. + +- [ ] **Step 6: Run signature and context tests** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +``` + +Expected: all tests PASS. + +- [ ] **Step 7: Commit signature resolution** + +```bash +git add apps/desktop/src/lib/sql/sqlCompletion.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts +git commit -m "feat(sql): support ClickHouse function overloads" +``` + +## Task 6: Render Multiple Signatures in the Query Editor + +**Files:** + +- Create: `apps/desktop/src/lib/editor/sqlSignatureTooltip.ts` +- Create: `apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts` +- Modify: `apps/desktop/src/components/editor/QueryEditor.vue` + +- [ ] **Step 1: Add a failing real-DOM tooltip test** + +Create `sqlSignatureTooltip.spec.ts`: + +```ts +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip"; + +describe("SQL signature tooltip", () => { + it("renders every overload and highlights the active parameter in each one", () => { + const dom = createSqlSignatureTooltipDom({ + name: "toStartOfInterval", + activeOverload: 0, + overloads: [ + { + signature: "toStartOfInterval(value, interval)", + parameterGroups: [["value", "interval"]], + activeGroup: 0, + activeParameter: 1, + }, + { + signature: "toStartOfInterval(value, interval, time_zone)", + parameterGroups: [["value", "interval", "time_zone"]], + activeGroup: 0, + activeParameter: 1, + }, + ], + }); + + expect(dom.textContent).toContain("1/2"); + expect(dom.textContent).toContain("2/2"); + expect(dom.textContent).toContain("time_zone"); + expect(dom.querySelectorAll("[data-active-parameter='true']")).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run the editor test and confirm RED** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts +``` + +Expected: FAIL because `sqlSignatureTooltip.ts` does not exist. + +- [ ] **Step 3: Implement the overload-aware DOM renderer** + +Create `sqlSignatureTooltip.ts`: + +```ts +import type { SqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion"; + +export function createSqlSignatureTooltipDom(signature: SqlFunctionSignatureHelp | null): HTMLElement { + const dom = document.createElement("div"); + dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"; + if (!signature) return dom; + + signature.overloads.forEach((overload, overloadIndex) => { + const row = document.createElement("div"); + row.className = overloadIndex > 0 ? "mt-1 flex items-center gap-2 font-mono" : "flex items-center gap-2 font-mono"; + if (signature.overloads.length > 1) { + const count = document.createElement("span"); + count.className = "text-[10px] text-muted-foreground"; + count.textContent = `${overloadIndex + 1}/${signature.overloads.length}`; + row.appendChild(count); + } + + const call = document.createElement("span"); + const name = document.createElement("span"); + name.className = "text-muted-foreground"; + name.textContent = signature.name; + call.appendChild(name); + + overload.parameterGroups.forEach((group, groupIndex) => { + const open = document.createElement("span"); + open.className = "text-muted-foreground"; + open.textContent = "("; + call.appendChild(open); + group.forEach((parameter, parameterIndex) => { + if (parameterIndex > 0) call.append(", "); + const node = document.createElement("span"); + const active = groupIndex === overload.activeGroup && parameterIndex === overload.activeParameter; + node.className = active ? "font-semibold text-foreground" : "text-muted-foreground"; + if (active) node.dataset.activeParameter = "true"; + node.textContent = parameter; + call.appendChild(node); + }); + call.append(")"); + }); + + row.appendChild(call); + dom.appendChild(row); + }); + return dom; +} +``` + +- [ ] **Step 4: Wire the renderer into QueryEditor** + +Import `createSqlSignatureTooltipDom`, delete the local `createSignatureDom`, and change the signature extension to: + +```ts +create: () => ({ dom: createSqlSignatureTooltipDom(signature) }), +``` + +- [ ] **Step 5: Run editor, signature, and context tests** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +``` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit tooltip rendering** + +```bash +git add apps/desktop/src/lib/editor/sqlSignatureTooltip.ts apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts apps/desktop/src/components/editor/QueryEditor.vue +git commit -m "feat(editor): render SQL function overloads" +``` + +## Task 7: Audit Coverage and Run Full Verification + +**Files:** + +- Modify only if verification finds an issue: files from Tasks 1–6. + +- [ ] **Step 1: Run registry integrity and review category floors** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts +``` + +Expected: all tests PASS; every checked-in category floor equals its reviewed inventory count. + +- [ ] **Step 2: Run the complete SQL completion test set** + +```bash +pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts +``` + +Expected: all tests PASS with zero failures. + +- [ ] **Step 3: Run TypeScript checking** + +```bash +pnpm typecheck +``` + +Expected: exit code 0 with no Vue or TypeScript errors. + +- [ ] **Step 4: Run lint on every changed TypeScript and Vue file** + +```bash +pnpm exec oxlint --vue-plugin \ + apps/desktop/src/lib/sql/clickhouse \ + apps/desktop/src/lib/sql/sqlCompletion.ts \ + apps/desktop/src/lib/editor/sqlSignatureTooltip.ts \ + apps/desktop/src/components/editor/QueryEditor.vue \ + apps/desktop/src/lib/__tests__/sql/clickhouse \ + apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts \ + apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts \ + apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts \ + apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts +``` + +Expected: exit code 0 with no lint errors. + +- [ ] **Step 5: Inspect the final diff against the approved design** + +```bash +git diff --check +git status --short +git log --oneline -7 +``` + +Confirm that: + +- no backend or server metadata query was added; +- ClickHouse names retain canonical casing; +- regular, aggregate, window, and table functions are represented; +- overloads and multiple parameter groups are tested; +- invalid combinator order is rejected; +- non-ClickHouse regression tests pass; +- only the planned files changed. + +- [ ] **Step 6: Commit any verification-only corrections** + +If Step 1–5 required a correction, stage only the corrected planned files and commit: + +```bash +git commit -m "fix(sql): complete ClickHouse function verification" +``` + +If no correction was required, do not create an empty commit. diff --git a/docs/superpowers/specs/2026-07-30-clickhouse-function-completion-design.md b/docs/superpowers/specs/2026-07-30-clickhouse-function-completion-design.md new file mode 100644 index 000000000..c85e0ae57 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-clickhouse-function-completion-design.md @@ -0,0 +1,301 @@ +# ClickHouse Function Completion Design + +Date: 2026-07-30 + +## Summary + +DBX currently provides database-specific static function completion for MySQL, PostgreSQL, SQLite, SQL Server, and Manticore Search. It also loads stored routines dynamically for selected databases. ClickHouse receives only the small common SQL function set, so native functions such as `toStartOfDay`, `uniqExact`, and `JSONExtractString` are absent. + +This change adds a static, ClickHouse-specific function registry with broad official-function coverage, overload-aware signature help, ClickHouse parametric aggregate support, table-function completion, and aggregate combinator generation. It does not query the connected ClickHouse server. + +## Goals + +- Provide broad completion coverage for documented ClickHouse regular, aggregate, window, and table functions. +- Preserve canonical ClickHouse casing when inserting camelCase function names. +- Support multiple signatures per function and highlight the active parameter. +- Support ClickHouse functions with multiple parameter groups, such as `quantilesTDigest(levels...)(expression)`. +- Generate valid aggregate-combinator completions without storing an unbounded Cartesian product. +- Keep existing MySQL, PostgreSQL, SQLite, SQL Server, and Manticore Search completion behavior unchanged. +- Keep the ClickHouse inventory maintainable and isolated from the already large generic SQL completion module. + +## Non-goals + +- Query `system.functions`, `system.user_defined_functions`, or any other server metadata table. +- Complete server-defined UDFs in the first version. +- Treat operators, keywords, or data types as functions. +- Include undocumented internal functions without a stable user-facing interface. +- Migrate every existing database function inventory to the new representation in the same change. + +## Existing Design to Reuse + +`sqlCompletion.ts` already provides: + +- database-specific function filtering; +- prefix matching and ranking; +- function snippets with CodeMirror placeholders; +- function-versus-keyword de-duplication; +- a signature tooltip driven by the cursor's active argument; +- regression coverage proving that database-specific functions do not leak across dialects. + +The existing `DATABASE_FUNCTION_SIGNATURES` maps demonstrate the desired database isolation, but their `Map` value type represents only one flat signature. ClickHouse requires a richer model because overloads and two-stage parameter lists are common. + +The dynamic stored-routine path used by MySQL, PostgreSQL, SQL Server, and Oracle is not reused for built-in ClickHouse functions. Static built-in completion and dynamic stored-routine completion solve different problems. + +## Architecture + +Add a ClickHouse-specific directory: + +```text +apps/desktop/src/lib/sql/clickhouse/ + functionTypes.ts + regularFunctions.ts + aggregateFunctions.ts + tableFunctions.ts + aggregateCombinators.ts + functionRegistry.ts +``` + +Responsibilities: + +- `functionTypes.ts` defines function, overload, parameter-group, category, and lifecycle-status types. +- `regularFunctions.ts` contains documented scalar functions, organized into readable category sections. +- `aggregateFunctions.ts` contains ordinary and parametric aggregate functions. +- `tableFunctions.ts` contains functions valid in table-target contexts such as `FROM` and `JOIN`. +- `aggregateCombinators.ts` defines suffix compatibility, ordering, signature transformations, and lazy generation. +- `functionRegistry.ts` merges definitions, validates lookup keys, performs case-insensitive lookup, preserves canonical names, and exposes prefix queries. +- `sqlCompletion.ts` adapts registry results into the existing generic completion item type and uses the richer signatures for tooltip rendering. + +The ClickHouse module contains data and deterministic transformation logic only. It does not depend on Vue, CodeMirror, a connection, or backend APIs. + +## Function Model + +The shared model is: + +```ts +interface SqlFunctionDefinition { + name: string; + category: SqlFunctionCategory; + signatures: SqlFunctionSignature[]; + description?: string; + preferredSignature?: number; + aggregate?: boolean; + status?: "stable" | "experimental" | "deprecated"; + aliases?: string[]; +} + +interface SqlFunctionSignature { + parameterGroups: string[][]; + returnType?: string; +} +``` + +One parameter group represents an ordinary function: + +```ts +{ + name: "toStartOfInterval", + category: "date-time", + signatures: [ + { + parameterGroups: [["value", "INTERVAL x unit"]], + returnType: "DateTime" + }, + { + parameterGroups: [["value", "INTERVAL x unit", "time_zone"]], + returnType: "DateTime" + } + ] +} +``` + +Multiple groups represent a parametric aggregate: + +```ts +{ + name: "quantilesTDigest", + category: "aggregate", + aggregate: true, + signatures: [ + { + parameterGroups: [ + ["level", "...levels"], + ["expression"] + ] + } + ] +} +``` + +`preferredSignature` selects the inserted snippet. When it is omitted, the first signature is preferred. Optional arguments remain visible in alternate signatures but are excluded from the default insertion when a shorter common form exists. + +## Inventory Scope + +The static inventory follows the public ClickHouse function reference and records the documentation snapshot date in source comments. It covers: + +- regular scalar functions, including array, string, date/time, JSON, Map, Tuple, URL, IP, mathematical, hashing, encoding, conversion, Nullable, dictionary, geography, and related documented categories; +- regular and parametric aggregate functions; +- window functions; +- table functions; +- documented aliases with canonical insertion names; +- experimental and deprecated functions, marked with status and ranked below stable functions. + +The official ClickHouse documentation separates regular, aggregate, table, window, and user-defined functions. The first version covers the first four static categories and excludes user-defined functions: + +- +- + +Every function must have an official name and an accurate parameter-group shape. A concise description is optional. When absent, the completion detail falls back to `ClickHouse · ` so full internationalization coverage is not required to ship the inventory. + +The checked-in inventory also contains a category manifest that records the documentation pages used for the 2026-07-30 snapshot and the number of definitions in each category. Integrity tests use those checked-in category counts as minimum floors, so removing a documented batch requires an intentional manifest update rather than silently reducing coverage. + +## Completion Behavior + +Matching is case-insensitive, while labels and inserted text retain canonical ClickHouse casing. For example, `tostart` can match and insert `toStartOfDay(${value})`. + +Each completion item displays: + +- canonical function name; +- `ClickHouse` and the function category; +- the preferred signature; +- overload count when greater than one; +- experimental or deprecated status; +- a concise description when available. + +Accepting a completion: + +- inserts the preferred signature with CodeMirror placeholders; +- avoids adding a second opening parenthesis when the user already typed one; +- inserts `function()` for zero-argument functions; +- preserves multiple parameter groups for parametric functions; +- continues to use existing ranking history and prefix scoring. + +ClickHouse-specific signatures override common SQL signatures for names shared with the common registry. + +## Table Functions + +Table functions are a distinct function kind. In ClickHouse table-target contexts, including `FROM` and `JOIN`, the provider includes matching table functions alongside tables and views. It does not include ordinary scalar or aggregate functions in those exclusive contexts. + +Table-function completions use the same overload and placeholder model as regular functions. This requires a narrow exception to the existing `exclusiveTableSuggestions` path rather than globally enabling all functions after `FROM`. + +## Signature Help + +The generic signature-help result is extended from one flat signature to multiple overloads containing one or more parameter groups. + +The resolver: + +1. finds the function call that owns the cursor; +2. determines the active parameter group; +3. counts top-level commas only within that group; +4. ranks signatures that can accept the observed argument count first; +5. returns every matching overload for display; +6. highlights the active parameter in each displayed overload. + +For `quantilesTDigest(0.5, 0.9)(value)`, the resolver recognizes both pairs of parentheses as one function call. It can distinguish the level list from the expression list. + +Ordinary existing database functions are adapted as one overload with one parameter group, preserving their current behavior. + +## Aggregate Combinators + +Aggregate combinators are generated lazily from aggregate definitions. The rules describe: + +- which base aggregates accept a combinator; +- valid suffix order; +- how parameter groups change; +- how return metadata changes; +- whether a combinator can be followed by another combinator. + +The initial rule set covers the documented combinators, including `If`, `Array`, `Map`, `SimpleState`, `State`, `Merge`, `MergeState`, `ForEach`, `Distinct`, `OrDefault`, `OrNull`, and `Resample`. + +Generation follows official ordering constraints. For example, `Array` precedes `If`, producing `uniqArrayIf`, while `uniqIfArray` is not generated. `If` appends a condition argument to the data-argument group. State and merge combinators transform the expected input or output shape rather than merely renaming the function. + +The registry generates candidates only for the active prefix and enforces a deterministic maximum candidate count. It does not materialize every possible suffix permutation at module initialization. + +## Ranking and Lifecycle Status + +Stable direct functions rank above generated combinator variants when match quality is equal. Exact prefix and exact label matches continue to receive the existing strong boosts. + +Generated variants remain competitive once the typed prefix includes their suffix. Experimental and deprecated entries receive a modest negative boost and a visible status label, but remain discoverable. + +Aliases de-duplicate against canonical functions case-insensitively. An alias can match the typed prefix, but accepting it inserts the canonical ClickHouse function name and casing. + +## Validation and Failure Handling + +The static path has no network failures. Registry validation catches authoring problems: + +- duplicate case-insensitive names; +- aliases colliding with canonical names; +- empty signature lists; +- empty parameter groups where they are not meaningful; +- out-of-range preferred signature indexes; +- invalid lifecycle status; +- illegal combinator ordering; +- combinator cycles; +- aliases that do not belong to a canonical definition. + +Tests fail on invalid inventory data. At runtime, an isolated malformed entry is skipped rather than breaking the entire completion popup, while development builds may log a diagnostic. + +## Testing Strategy + +Implementation follows test-driven development: add one failing behavioral test, confirm the expected failure, add the minimum implementation, and keep the focused suite green before proceeding. + +### Registry integrity + +- canonical names and case-insensitive lookup keys are unique; +- every definition has at least one valid signature; +- preferred signature indexes are valid; +- every declared category has representative coverage; +- an expected inventory-size floor guards against accidental bulk deletion. + +### Completion behavior + +- `toStart` returns canonical camelCase ClickHouse functions; +- completion inserts the preferred placeholder template; +- an already typed `(` is not duplicated; +- common functions use ClickHouse-specific signatures; +- ClickHouse-only names do not appear for MySQL or PostgreSQL. + +### Multiple signatures + +- all overloads are available to signature help; +- overload ranking reacts to top-level comma count; +- active parameters are highlighted correctly; +- parametric aggregate calls distinguish the first and second parameter groups. + +### Combinators + +- valid examples such as `sumIf`, `uniqArrayIf`, and `quantilesState` are generated; +- invalid suffix orders such as `uniqIfArray` are not generated; +- `If` adds the condition parameter; +- generation respects its deterministic result bound. + +### Table functions + +- ClickHouse table functions appear in `FROM` and `JOIN`; +- scalar and aggregate functions remain excluded from exclusive table contexts; +- ordinary table and view completion continues to work. + +### Regression coverage + +- current MySQL function suggestions and special insertion templates remain unchanged; +- PostgreSQL, SQLite, SQL Server, and Manticore Search functions remain isolated; +- existing snippet, keyword, column, table, and routine completion tests remain green. + +Final verification includes the focused SQL completion tests, frontend type checking, and the repository's relevant frontend test suite. + +## Success Criteria + +- A ClickHouse query can autocomplete representative functions from every supported category using canonical casing. +- Multiple overloads and current-parameter highlighting work for ordinary and parametric functions. +- Valid aggregate combinator variants are discoverable without invalid-order noise or eager combinatorial expansion. +- Table functions are suggested only where a table source is valid. +- No server query is required for built-in completion. +- Existing database-specific completion behavior remains covered by passing regression tests. + +## Future Work + +Possible follow-up work, intentionally excluded from this implementation: + +- optional discovery of SQL or executable UDFs from ClickHouse system tables; +- a generated inventory pipeline sourced from an official machine-readable index; +- migration of other database function maps to the overload-aware registry; +- localized descriptions for the complete ClickHouse inventory.