feat(sql): add Doris/SelectDB completion and semantic diagnostics
This commit is contained in:
10 files changed
+6273
-17
No files matched your search
@@ -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"] })]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`]));
|
||||||
@@ -48,6 +48,12 @@ function roleForGenericQualifier(parts: string[], context: "table" | "column" |
|
|||||||
return "schema";
|
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> = {
|
export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> = {
|
||||||
generic: {
|
generic: {
|
||||||
id: "generic",
|
id: "generic",
|
||||||
@@ -77,11 +83,19 @@ export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> =
|
|||||||
projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true },
|
projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true },
|
||||||
normalizeIdentifier: defaultNormalize,
|
normalizeIdentifier: defaultNormalize,
|
||||||
quoteIdentifier: (identifier) => quoteWith(identifier, "`"),
|
quoteIdentifier: (identifier) => quoteWith(identifier, "`"),
|
||||||
qualifierRole(parts, context) {
|
qualifierRole: roleForMysqlLikeQualifier,
|
||||||
if (context === "column") return parts.length >= 2 ? "table" : "table";
|
},
|
||||||
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
|
doris: {
|
||||||
return parts.length >= 1 ? "schema" : "unknown";
|
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: {
|
clickhouse: {
|
||||||
id: "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 {
|
export function sqlReferenceAnalysisDialectFor(options: { databaseType?: DatabaseType; identifierQuote?: string; fallbackDialect: string }): string {
|
||||||
if (options.databaseType === "kyuubi") return "spark";
|
if (options.databaseType === "kyuubi") return "spark";
|
||||||
if (options.databaseType === "kingbase" && options.identifierQuote === "`") return "mysql";
|
if (options.databaseType === "kingbase" && options.identifierQuote === "`") return "mysql";
|
||||||
|
if (options.databaseType === "doris" || options.databaseType === "starrocks") return "doris";
|
||||||
return options.fallbackDialect;
|
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;
|
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];
|
if (options.dialect && SQL_SEMANTIC_DIALECTS[options.dialect]) return SQL_SEMANTIC_DIALECTS[options.dialect];
|
||||||
switch (options.databaseType) {
|
switch (options.databaseType) {
|
||||||
case "postgres":
|
case "postgres":
|
||||||
@@ -171,8 +191,6 @@ export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; di
|
|||||||
case "uxdb":
|
case "uxdb":
|
||||||
return SQL_SEMANTIC_DIALECTS.postgres;
|
return SQL_SEMANTIC_DIALECTS.postgres;
|
||||||
case "mysql":
|
case "mysql":
|
||||||
case "doris":
|
|
||||||
case "starrocks":
|
|
||||||
return SQL_SEMANTIC_DIALECTS.mysql;
|
return SQL_SEMANTIC_DIALECTS.mysql;
|
||||||
case "sqlserver":
|
case "sqlserver":
|
||||||
return SQL_SEMANTIC_DIALECTS.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
|
* "generic" default), matching tokenizeSqlSemantic's default and preserving the behavior callers
|
||||||
* had before dialect-aware scanning existed.
|
* 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";
|
return options.databaseType || options.dialect ? sqlSemanticDialectFor(options).id : "mysql";
|
||||||
}
|
}
|
||||||
@@ -464,6 +464,46 @@ function parseRowSourceList(state: ParseState, target: number, introducer: strin
|
|||||||
return parsed ? { sources: [parsed.source], nextIndex: parsed.nextIndex } : null;
|
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[] {
|
function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIndexOffset = 0): SqlSemanticRowSource[] {
|
||||||
const sources: SqlSemanticRowSource[] = [];
|
const sources: SqlSemanticRowSource[] = [];
|
||||||
let inSelectFromClause = false;
|
let inSelectFromClause = false;
|
||||||
@@ -497,9 +537,20 @@ function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIn
|
|||||||
sources.push(...parsed.sources);
|
sources.push(...parsed.sources);
|
||||||
index = parsed.nextIndex - 1;
|
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;
|
if (normalized !== "from" || separator?.text !== "," || separator.depth !== sourceDepth) break;
|
||||||
target = parsed.nextIndex + 1;
|
target = afterSources + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dedupeSources(sources);
|
return dedupeSources(sources);
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql", options?
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ch === "#" && dialectId === "mysql") {
|
if (ch === "#" && (dialectId === "mysql" || dialectId === "doris")) {
|
||||||
index += 1;
|
index += 1;
|
||||||
while (index < input.length && input[index] !== "\n" && input[index] !== "\r") 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));
|
tokens.push(token("comment", input.slice(start, index), start, index, depth));
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export interface SqlSemanticModel {
|
|||||||
|
|
||||||
export interface SqlSemanticBuildOptions {
|
export interface SqlSemanticBuildOptions {
|
||||||
databaseType?: DatabaseType;
|
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
|
* 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
|
* real editor completion request, not a pure-string test/utility call). When present and its
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identif
|
|||||||
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
|
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
|
||||||
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
||||||
import { driverProfileCompletionObjects, driverProfileCompletionTableMetadata, driverProfileCompletionTables, driverProfileRoutineSignatures } from "@/lib/database/driverProfileExtensions";
|
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";
|
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,
|
manticoresearch: MANTICORESEARCH_SQL_KEYWORDS,
|
||||||
duckdb: ["COMMENT"],
|
duckdb: ["COMMENT"],
|
||||||
clickhouse: ["COMMENT"],
|
clickhouse: ["COMMENT"],
|
||||||
doris: ["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"],
|
starrocks: ["COMMENT", "MATERIALIZED", "MATERIALIZED VIEW", "DISTRIBUTED BY HASH", "DUPLICATE KEY", "PROPERTIES", "PARTITION BY", "BUCKETS", "LATERAL VIEW"],
|
||||||
dameng: ["COMMENT"],
|
dameng: ["COMMENT"],
|
||||||
kingbase: ["COMMENT"],
|
kingbase: ["COMMENT"],
|
||||||
vastbase: ["COMMENT"],
|
vastbase: ["COMMENT"],
|
||||||
@@ -648,7 +649,8 @@ const EXACT_LABEL_MATCH_BOOST = 10000;
|
|||||||
const EXACT_CUSTOM_SNIPPET_TRIGGER_BOOST = 40000;
|
const EXACT_CUSTOM_SNIPPET_TRIGGER_BOOST = 40000;
|
||||||
|
|
||||||
function isTableTriggerKeyword(keyword: string, options: SqlSemanticBuildOptions): boolean {
|
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)
|
// 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,
|
"cloudflare-d1": CLOUDFLARE_D1_FUNCTION_SIGNATURES,
|
||||||
sqlserver: SQLSERVER_FUNCTION_SIGNATURES,
|
sqlserver: SQLSERVER_FUNCTION_SIGNATURES,
|
||||||
manticoresearch: MANTICORESEARCH_FUNCTION_SIGNATURES,
|
manticoresearch: MANTICORESEARCH_FUNCTION_SIGNATURES,
|
||||||
|
doris: DORIS_FUNCTION_SIGNATURES,
|
||||||
|
starrocks: DORIS_FUNCTION_SIGNATURES,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MYSQL_FUNCTION_APPLY_TEMPLATES = new Map<string, string>([
|
const MYSQL_FUNCTION_APPLY_TEMPLATES = new Map<string, string>([
|
||||||
@@ -5039,6 +5043,13 @@ function activeFunctionSignatures(databaseType?: DatabaseType): Map<string, stri
|
|||||||
return signatures;
|
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 {
|
function formatFunctionSignatureApply(definition: ClickHouseFunctionDefinition, omitOpeningParen: boolean): string {
|
||||||
if (omitOpeningParen) return definition.name;
|
if (omitOpeningParen) return definition.name;
|
||||||
const signature = definition.signatures[definition.preferredSignature ?? 0];
|
const signature = definition.signatures[definition.preferredSignature ?? 0];
|
||||||
@@ -5089,7 +5100,7 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map<str
|
|||||||
items.push({
|
items.push({
|
||||||
label: functionName,
|
label: functionName,
|
||||||
type: "function" as const,
|
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})`,
|
apply: mysqlApply ? `${functionName}${applyGeneratedSqlTemplateKeywordCase(mysqlApply.slice(name.length), keywordCase)}` : `${functionName}(${paramStr})`,
|
||||||
boost: computeBoost(name, prefix) + 300,
|
boost: computeBoost(name, prefix) + 300,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`);
|
||||||
Reference in new issue
Block a user