feat(sql): add Doris/SelectDB completion and semantic diagnostics

This commit is contained in:
zipg
2026-09-12 17:36:58 +08:00
committed by GitHub
parent 20f6092cf2
commit f8ce8ab353
10 changed files with 6273 additions and 17 deletions
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { buildSqlCompletionItems, getSqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { resolveSqlDialectId, sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
describe("Doris and SelectDB SQL assistance", () => {
it.each(["doris", "selectdb"])("routes the %s profile to Doris assistance", (driver_profile) => {
expect(effectiveDatabaseTypeForConnection({ db_type: "mysql", driver_profile })).toBe("doris");
});
it.each(["BITMAP_UNION_COUNT", "HLL_UNION_AGG", "ARRAY_MAP", "JSON_EXTRACT_STRING", "DATE_TRUNC", "PERCENTILE_APPROX", "EXPLODE_SPLIT"])("offers %s with an insertable signature", (name) => {
const sql = `SELECT ${name.slice(0, -1)}`;
const items = buildSqlCompletionItems(sql, sql.length, { databaseType: "doris", tables: [], columnsByTable: new Map() });
expect(items).toContainEqual(expect.objectContaining({ label: name, type: "function", apply: expect.stringContaining(`${name}(`) }));
const call = `SELECT ${name}(`;
expect(getSqlFunctionSignatureHelp(call, call.length, "doris")).not.toBeNull();
});
it("ranks a matching column ahead of a function prefix match", () => {
const sql = "SELECT bitmap_ FROM events";
const items = buildSqlCompletionItems(sql, sql.indexOf(" FROM"), {
databaseType: "doris",
tables: [{ name: "events" }],
columnsByTable: new Map([["events", [{ name: "bitmap_value", table: "events", dataType: "BITMAP" }]]]),
});
expect(items.findIndex((item) => item.label === "bitmap_value")).toBeGreaterThanOrEqual(0);
expect(items.findIndex((item) => item.label === "BITMAP_UNION_COUNT")).toBeGreaterThan(items.findIndex((item) => item.label === "bitmap_value"));
});
it("retains a Doris parser dialect instead of falling back to MySQL", () => {
expect(sqlReferenceAnalysisDialectFor({ databaseType: "doris", fallbackDialect: "mysql" })).toBe("doris");
});
it("models chained LATERAL VIEW outputs as local columns", () => {
const sql = "SELECT e, part FROM events t LATERAL VIEW explode(t.tags) a AS e LATERAL VIEW explode_split(t.name, ',') b AS part";
const model = buildSqlSemanticModel(sql, sql.indexOf("e,") + 1, { databaseType: "doris", dialect: "doris" });
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "a", kind: "table_function", columns: ["e"] }), expect.objectContaining({ name: "b", kind: "table_function", columns: ["part"] })]));
});
it("keeps the Doris semantic adapter on the real editor dialect shape", () => {
// QueryEditor passes the MySQL fallback dialect for Doris connections; the databaseType must
// still win so the doris adapter (LATERAL VIEW modeling, ...) is not masked by "mysql".
expect(resolveSqlDialectId({ databaseType: "doris", dialect: "mysql" })).toBe("doris");
expect(resolveSqlDialectId({ databaseType: "starrocks", dialect: "mysql" })).toBe("doris");
});
it("treats # as a line comment for Doris", () => {
const tokens = tokenizeSqlSemantic("SELECT 1 # trailing note\nFROM t", "doris");
expect(tokens).toContainEqual(expect.objectContaining({ kind: "comment", text: "# trailing note" }));
});
it("models LATERAL VIEW columns under the editor dialect shape", () => {
const sql = "SELECT e FROM events t LATERAL VIEW explode(t.tags) a AS e";
const model = buildSqlSemanticModel(sql, sql.indexOf("e") + 1, { databaseType: "doris", dialect: "mysql" });
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "a", kind: "table_function", columns: ["e"] })]));
});
it("models LATERAL VIEW OUTER like the plain form", () => {
const sql = "SELECT e FROM events t LATERAL VIEW OUTER explode(t.tags) a AS e";
const model = buildSqlSemanticModel(sql, sql.indexOf("e") + 1, { databaseType: "doris", dialect: "mysql" });
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "a", kind: "table_function", columns: ["e"] })]));
});
});
+16
View File
@@ -0,0 +1,16 @@
# Doris and SelectDB SQL assistance
The function catalogue is derived from the Apache Doris 2.1 and 3.x SQL manuals.
SelectDB connections use the Doris capability family through `jdbcDialect.ts`.
Regenerate the catalogue from an Apache `doris-website` checkout:
```sh
node scripts/generate-doris-functions.mjs /path/to/doris-website
```
The generated file records the source revision and per-function documentation
links. `documentedIn` records documentation coverage, not a minimum server
version: the 3.x manuals also contain functions added after 3.0.
Source: https://github.com/apache/doris-website (Apache License 2.0).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
import catalog from "@/lib/sql/doris/functions.generated.json";
export interface DorisFunctionDefinition {
name: string;
category: string;
signatures: string[];
docs: string;
documentedIn: string[];
}
const definitions = catalog.functions as DorisFunctionDefinition[];
function splitParameters(value: string): string[] {
const parameters: string[] = [];
let depth = 0;
let start = 0;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === "(" || character === "<" || character === "[") depth += 1;
if (character === ")" || character === ">" || character === "]") depth = Math.max(0, depth - 1);
if (character === "," && depth === 0) {
parameters.push(value.slice(start, index).trim());
start = index + 1;
}
}
const last = value.slice(start).trim();
if (last) parameters.push(last);
return parameters;
}
function parameterName(value: string, index: number): string {
const placeholder = /<([^>]+)>/.exec(value)?.[1] ?? value;
const cleaned = placeholder
.replace(/\[.*$/g, "")
.replace(/\s+/g, "_")
.replace(/[^A-Za-z0-9_]/g, "")
.replace(/^_+|_+$/g, "");
return cleaned || `arg${index + 1}`;
}
function parametersFor(definition: DorisFunctionDefinition): string[] {
const signature = definition.signatures.find((item) => item.includes("("));
if (!signature) return ["expression"];
const open = signature.indexOf("(");
const close = signature.lastIndexOf(")");
if (open < 0 || close <= open) return ["expression"];
return splitParameters(signature.slice(open + 1, close))
.filter((item) => item && !/^\.\.\.$/.test(item))
.map(parameterName);
}
export const DORIS_FUNCTION_DEFINITIONS = definitions;
const compatibilityOverrides: DorisFunctionDefinition[] = [
{
name: "JSON_EXTRACT_STRING",
category: "JSON",
signatures: ["JSON_EXTRACT_STRING(json_string, path)"],
docs: "Extracts a string value from a JSON string by path.",
documentedIn: ["Apache Doris JSON function compatibility"],
},
];
for (const definition of compatibilityOverrides) {
if (!DORIS_FUNCTION_DEFINITIONS.some((item) => item.name === definition.name)) DORIS_FUNCTION_DEFINITIONS.push(definition);
}
export const DORIS_FUNCTION_SIGNATURES = new Map(DORIS_FUNCTION_DEFINITIONS.map((definition) => [definition.name, parametersFor(definition)]));
export const DORIS_FUNCTION_DOCS = new Map(DORIS_FUNCTION_DEFINITIONS.map((definition) => [definition.name, `${definition.category} · ${definition.docs}`]));
+27 -9
View File
@@ -48,6 +48,12 @@ function roleForGenericQualifier(parts: string[], context: "table" | "column" |
return "schema";
}
function roleForMysqlLikeQualifier(parts: string[], context: "table" | "column" | "routine"): "catalog" | "schema" | "table" | "package" | "unknown" {
if (context === "column") return "table";
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
return parts.length >= 1 ? "schema" : "unknown";
}
export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> = {
generic: {
id: "generic",
@@ -77,11 +83,19 @@ export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> =
projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true },
normalizeIdentifier: defaultNormalize,
quoteIdentifier: (identifier) => quoteWith(identifier, "`"),
qualifierRole(parts, context) {
if (context === "column") return parts.length >= 2 ? "table" : "table";
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
return parts.length >= 1 ? "schema" : "unknown";
},
qualifierRole: roleForMysqlLikeQualifier,
},
doris: {
id: "doris",
identifierQuotes: [
{ open: "`", close: "`" },
{ open: '"', close: '"' },
],
supportsAsForTableAlias: true,
projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true },
normalizeIdentifier: defaultNormalize,
quoteIdentifier: (identifier) => quoteWith(identifier, "`"),
qualifierRole: roleForMysqlLikeQualifier,
},
clickhouse: {
id: "clickhouse",
@@ -156,11 +170,17 @@ export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> =
export function sqlReferenceAnalysisDialectFor(options: { databaseType?: DatabaseType; identifierQuote?: string; fallbackDialect: string }): string {
if (options.databaseType === "kyuubi") return "spark";
if (options.databaseType === "kingbase" && options.identifierQuote === "`") return "mysql";
if (options.databaseType === "doris" || options.databaseType === "starrocks") return "doris";
return options.fallbackDialect;
}
export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" }): SqlSemanticDialectAdapter {
export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris" }): SqlSemanticDialectAdapter {
if (options.databaseType === "clickhouse") return SQL_SEMANTIC_DIALECTS.clickhouse;
// Doris/StarRocks connections ride the editor's MySQL fallback dialect (codeMirrorSqlDialect maps
// them to "mysql"), so the explicit-dialect branch below would otherwise mask the doris adapter
// (LATERAL VIEW modeling, etc.) on the real editor path. Like clickhouse, they win on
// databaseType regardless of the passed dialect.
if (options.databaseType === "doris" || options.databaseType === "starrocks") return SQL_SEMANTIC_DIALECTS.doris;
if (options.dialect && SQL_SEMANTIC_DIALECTS[options.dialect]) return SQL_SEMANTIC_DIALECTS[options.dialect];
switch (options.databaseType) {
case "postgres":
@@ -171,8 +191,6 @@ export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; di
case "uxdb":
return SQL_SEMANTIC_DIALECTS.postgres;
case "mysql":
case "doris":
case "starrocks":
return SQL_SEMANTIC_DIALECTS.mysql;
case "sqlserver":
return SQL_SEMANTIC_DIALECTS.sqlserver;
@@ -204,6 +222,6 @@ export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; di
* "generic" default), matching tokenizeSqlSemantic's default and preserving the behavior callers
* had before dialect-aware scanning existed.
*/
export function resolveSqlDialectId(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" }): string {
export function resolveSqlDialectId(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris" }): string {
return options.databaseType || options.dialect ? sqlSemanticDialectFor(options).id : "mysql";
}
+53 -2
View File
@@ -464,6 +464,46 @@ function parseRowSourceList(state: ParseState, target: number, introducer: strin
return parsed ? { sources: [parsed.source], nextIndex: parsed.nextIndex } : null;
}
function parseDorisLateralView(state: ParseState, index: number, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
if (state.dialect.id !== "doris" || state.tokens[index]?.normalized !== "lateral" || state.tokens[index + 1]?.normalized !== "view") return null;
// Doris's `LATERAL VIEW [OUTER] fn(...) alias AS col` -- skip the optional OUTER marker so the
// OUTER form models the same function columns as the plain form.
let functionIndex = index + 2;
if (state.tokens[functionIndex]?.normalized === "outer") functionIndex += 1;
const functionName = readQualifiedName(state.tokens, functionIndex, state.dialect);
if (!functionName || state.tokens[functionName.nextIndex]?.text !== "(") return null;
const close = findMatchingParenToken(state.tokens, functionName.nextIndex);
if (close < 0) return null;
let aliasIndex = close + 1;
if (state.tokens[aliasIndex]?.normalized === "as") aliasIndex += 1;
const alias = state.tokens[aliasIndex];
if (!alias || alias.kind !== "word") return null;
let columnIndex = aliasIndex + 1;
if (state.tokens[columnIndex]?.normalized === "as") columnIndex += 1;
const columns: string[] = [];
while (state.tokens[columnIndex]?.kind === "word") {
columns.push(state.tokens[columnIndex].text);
columnIndex += 1;
if (state.tokens[columnIndex]?.text !== ",") break;
columnIndex += 1;
}
const endToken = state.tokens[Math.max(aliasIndex, columnIndex - 1)] ?? alias;
const name = alias.text;
return {
source: {
id: `table-function:${sourceIndex}:${name}`,
kind: "table_function",
name,
alias: name,
qualifierParts: [name],
qualifiedName: functionName.name,
sourceSpan: { start: state.tokens[index].span.start, end: endToken.span.end },
columns: columns.length ? columns : undefined,
},
nextIndex: columnIndex,
};
}
function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIndexOffset = 0): SqlSemanticRowSource[] {
const sources: SqlSemanticRowSource[] = [];
let inSelectFromClause = false;
@@ -497,9 +537,20 @@ function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIn
sources.push(...parsed.sources);
index = parsed.nextIndex - 1;
const separator = state.tokens[parsed.nextIndex];
// Track the position past any LATERAL VIEW clauses so the FROM-list separator check sees
// the real comma after them (parsed.nextIndex points at the first "lateral" token itself).
let afterSources = parsed.nextIndex;
let lateral = parseDorisLateralView(state, afterSources, sourceIndexOffset + sources.length);
while (lateral) {
sources.push(lateral.source);
index = lateral.nextIndex - 1;
afterSources = lateral.nextIndex;
lateral = parseDorisLateralView(state, afterSources, sourceIndexOffset + sources.length);
}
const separator = state.tokens[afterSources];
if (normalized !== "from" || separator?.text !== "," || separator.depth !== sourceDepth) break;
target = parsed.nextIndex + 1;
target = afterSources + 1;
}
}
return dedupeSources(sources);
+1 -1
View File
@@ -128,7 +128,7 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql", options?
continue;
}
if (ch === "#" && dialectId === "mysql") {
if (ch === "#" && (dialectId === "mysql" || dialectId === "doris")) {
index += 1;
while (index < input.length && input[index] !== "\n" && input[index] !== "\r") index += 1;
tokens.push(token("comment", input.slice(start, index), start, index, depth));
+1 -1
View File
@@ -129,7 +129,7 @@ export interface SqlSemanticModel {
export interface SqlSemanticBuildOptions {
databaseType?: DatabaseType;
dialect?: "mysql" | "postgres" | "sqlserver";
dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris";
/**
* Live CodeMirror state for the document being edited, when the caller has one (i.e. this is a
* real editor completion request, not a pure-string test/utility call). When present and its
+15 -4
View File
@@ -18,6 +18,7 @@ import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identif
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { driverProfileCompletionObjects, driverProfileCompletionTableMetadata, driverProfileCompletionTables, driverProfileRoutineSignatures } from "@/lib/database/driverProfileExtensions";
import { DORIS_FUNCTION_DOCS, DORIS_FUNCTION_SIGNATURES } from "@/lib/sql/doris/functions";
export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
@@ -585,8 +586,8 @@ const DATABASE_SQL_KEYWORDS: Partial<Record<DatabaseType, string[]>> = {
manticoresearch: MANTICORESEARCH_SQL_KEYWORDS,
duckdb: ["COMMENT"],
clickhouse: ["COMMENT"],
doris: ["COMMENT", "MATERIALIZED", "MATERIALIZED VIEW"],
starrocks: ["COMMENT", "MATERIALIZED", "MATERIALIZED VIEW"],
doris: ["COMMENT", "MATERIALIZED", "MATERIALIZED VIEW", "DISTRIBUTED BY HASH", "DUPLICATE KEY", "AGGREGATE KEY", "UNIQUE KEY", "PRIMARY KEY", "PROPERTIES", "PARTITION BY", "BUCKETS", "LATERAL VIEW", "EXPLODE", "ARRAY", "MAP", "STRUCT", "BITMAP", "HLL"],
starrocks: ["COMMENT", "MATERIALIZED", "MATERIALIZED VIEW", "DISTRIBUTED BY HASH", "DUPLICATE KEY", "PROPERTIES", "PARTITION BY", "BUCKETS", "LATERAL VIEW"],
dameng: ["COMMENT"],
kingbase: ["COMMENT"],
vastbase: ["COMMENT"],
@@ -648,7 +649,8 @@ const EXACT_LABEL_MATCH_BOOST = 10000;
const EXACT_CUSTOM_SNIPPET_TRIGGER_BOOST = 40000;
function isTableTriggerKeyword(keyword: string, options: SqlSemanticBuildOptions): boolean {
return TABLE_TRIGGER_KEYWORDS.has(keyword) || (keyword === "desc" && resolveSqlDialectId(options) === "mysql");
const dialectId = resolveSqlDialectId(options);
return TABLE_TRIGGER_KEYWORDS.has(keyword) || (keyword === "desc" && (dialectId === "mysql" || dialectId === "doris"));
}
// Keywords that only make sense in DDL / statement-start contexts (not inside SELECT/INSERT/UPDATE/DELETE)
@@ -1131,6 +1133,8 @@ const DATABASE_FUNCTION_SIGNATURES: Partial<Record<DatabaseType, Map<string, str
"cloudflare-d1": CLOUDFLARE_D1_FUNCTION_SIGNATURES,
sqlserver: SQLSERVER_FUNCTION_SIGNATURES,
manticoresearch: MANTICORESEARCH_FUNCTION_SIGNATURES,
doris: DORIS_FUNCTION_SIGNATURES,
starrocks: DORIS_FUNCTION_SIGNATURES,
};
const MYSQL_FUNCTION_APPLY_TEMPLATES = new Map<string, string>([
@@ -5039,6 +5043,13 @@ function activeFunctionSignatures(databaseType?: DatabaseType): Map<string, stri
return signatures;
}
const DORIS_FUNCTION_DESCRIPTIONS = new Map(DORIS_FUNCTION_DOCS);
const EMPTY_FUNCTION_DESCRIPTIONS = new Map<string, string>();
function activeFunctionDescriptions(databaseType?: DatabaseType): Map<string, string> {
return databaseType === "doris" || databaseType === "starrocks" ? DORIS_FUNCTION_DESCRIPTIONS : EMPTY_FUNCTION_DESCRIPTIONS;
}
function formatFunctionSignatureApply(definition: ClickHouseFunctionDefinition, omitOpeningParen: boolean): string {
if (omitOpeningParen) return definition.name;
const signature = definition.signatures[definition.preferredSignature ?? 0];
@@ -5089,7 +5100,7 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map<str
items.push({
label: functionName,
type: "function" as const,
detail: functionDescriptions.get(name) ?? "function",
detail: activeFunctionDescriptions(databaseType).get(name) ?? functionDescriptions.get(name) ?? "function",
apply: mysqlApply ? `${functionName}${applyGeneratedSqlTemplateKeywordCase(mysqlApply.slice(name.length), keywordCase)}` : `${functionName}(${paramStr})`,
boost: computeBoost(name, prefix) + 300,
});
+65
View File
@@ -0,0 +1,65 @@
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { parse } from "yaml";
// Input: an Apache doris-website checkout containing the versioned SQL manuals.
const docsRoot = process.argv[2];
if (!docsRoot) throw new Error("Usage: node scripts/generate-doris-functions.mjs /path/to/doris-website");
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const revision = execFileSync("git", ["-C", docsRoot, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
function files(root) {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const path = join(root, entry.name);
return entry.isDirectory() ? files(path) : path.endsWith(".md") ? [path] : [];
});
}
function definitions(version) {
const root = join(docsRoot, `versioned_docs/version-${version}/sql-manual/sql-functions`);
return files(root).flatMap((path) => {
const text = readFileSync(path, "utf8");
const frontmatter = /^---\s*\n([\s\S]*?)\n---/.exec(text)?.[1];
const metadata = frontmatter ? parse(frontmatter) : {};
const name = String(metadata?.title ?? "")
.trim()
.toUpperCase();
if (!/^[A-Z][A-Z0-9_]*$/.test(name) || metadata?.draft || name === "OVERVIEW" || relative(root, path).startsWith("combinators/")) return [];
const syntax = /##+\s+Syntax\s*\n([\s\S]*?)(?=\n##|$)/i.exec(text)?.[1] ?? "";
const code = [...syntax.matchAll(/```[^\n]*\n([\s\S]*?)```|`([^`\n]+)`/g)].map((match) => match[1] ?? match[2]);
const signatures = [
...new Set(
code.flatMap((block) => {
const matches = [...block.matchAll(new RegExp(`\\b${name}\\s*\\(`, "gi"))];
return matches.flatMap((match) => {
const start = match.index;
let depth = 1;
let end = start + match[0].length;
while (end < block.length && depth > 0) {
if (block[end] === "(") depth++;
if (block[end] === ")") depth--;
end++;
}
return depth === 0 ? [block.slice(start, end).replace(/\s+/g, " ")] : [];
});
}),
),
];
const category = relative(root, dirname(path)).replaceAll("\\", "/");
const document = relative(root, path).replaceAll("\\", "/").replace(/\.md$/, "");
return [{ name, category, signatures, docs: `https://doris.apache.org/docs/${version}/sql-manual/sql-functions/${document}/` }];
});
}
const older = new Map(definitions("2.1").map((definition) => [definition.name, definition]));
const current = new Map(definitions("3.x").map((definition) => [definition.name, definition]));
const entries = [...current.values()].map((definition) => ({ ...definition, documentedIn: older.has(definition.name) ? ["2.1", "3.x"] : ["3.x"] }));
for (const definition of older.values()) {
if (!current.has(definition.name)) entries.push({ ...definition, documentedIn: ["2.1"] });
}
entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
const output = { source: "https://github.com/apache/doris-website", revision, license: "Apache-2.0", functions: entries };
writeFileSync(join(repoRoot, "apps/desktop/src/lib/sql/doris/functions.generated.json"), JSON.stringify(output, null, 2) + "\n");
console.log(`Generated ${entries.length} Doris functions from ${revision}`);