diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index b4b545726..4dba41b23 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -167,6 +167,39 @@ describe("useSqlExecution", () => { expect(executedSql).toContain("where fp.create_at < @date_start"); }); + it("sends Doris STRUCT DDL unchanged without opening the parameter dialog", async () => { + const sql = ` + create table \`events\` ( + \`field0\` int not null comment 'field 0', + \`field_list\` array> comment 'field list' + ) + engine = olap + properties ("replication_num" = "1"); + `; + const activeTab = ref(queryTab("analytics")); + const activeConnection = ref(connection("doris")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => { + if (activeTab.value) activeTab.value.result = { columns: ["ok"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }; + }); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + vi.spyOn(useConnectionStore(), "refreshObjectListTreeNode").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + }); + + await execution.tryExecute(); + + expect(execution.showSqlParameterDialog.value).toBe(false); + expect(execution.sqlParameterNames.value).toEqual([]); + expect(executeCurrentSql).toHaveBeenCalledWith(sql, {}); + }); + it("records a later MySQL batch error and skips metadata refresh", async () => { const activeTab = ref(queryTab("app")); const activeConnection = ref(connection("mysql")); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index 74366552f..9ce3cadc3 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -177,6 +177,93 @@ describe("extractSqlParameters", () => { expect(extractSqlParameters(sql)).toEqual(["actual_value"]); }); + it("ignores Doris STRUCT field type separators", () => { + const sql = ` + create table \`events\` ( + \`field0\` int not null comment 'field 0', + \`field_list\` array> comment 'field list' + ) + engine = olap + properties ("replication_num" = "1"); + `; + + expect(extractSqlParameters(sql, { databaseType: "doris" })).toEqual([]); + // SelectDB connections use the MySQL db type with a SelectDB driver profile. + expect(extractSqlParameters(sql, { databaseType: "mysql" })).toEqual([]); + expect(substituteSqlParameters(sql, {}, { databaseType: "doris" })).toBe(sql); + }); + + it("keeps named parameters that are not STRUCT field type separators", () => { + const sql = ` + create table \`events\` ( + \`field_list\` array + >> + ) properties ("buckets" = :bucket_count); + `; + + expect(extractSqlParameters(sql, { databaseType: "doris" })).toEqual(["precision", "scale", "bucket_count"]); + expect( + substituteSqlParameters( + sql, + { + precision: { kind: "number", value: "16" }, + scale: { kind: "number", value: "5" }, + bucket_count: { kind: "number", value: "8" }, + }, + { databaseType: "doris" }, + ), + ).toBe(` + create table \`events\` ( + \`field_list\` array + >> + ) properties ("buckets" = 8); + `); + }); + + it("does not let an unterminated complex type hide a later named parameter", () => { + const sql = "create table `broken` (value struct { + const sql = ` + create table \`events\` ( + value variant< + match_name 'path_1':decimal(:precision, :scale), + match_name_glob 'meta*':bigint, + properties('variant_max_subcolumns_count' = :property_value) + > + ); + `; + + expect(extractSqlParameters(sql, { databaseType: "doris" })).toEqual(["precision", "scale", "property_value"]); + expect( + substituteSqlParameters( + sql, + { + precision: { kind: "number", value: "16" }, + scale: { kind: "number", value: "5" }, + property_value: { kind: "string", value: "2048" }, + }, + { databaseType: "doris" }, + ), + ).toBe(` + create table \`events\` ( + value variant< + match_name 'path_1':decimal(16, 5), + match_name_glob 'meta*':bigint, + properties('variant_max_subcolumns_count' = '2048') + > + ); + `); + }); + it("ignores HANA SQLScript variable references", () => { const sql = "DO BEGIN Dummy1 = SELECT 1 FROM DUMMY; SELECT * FROM :Dummy1; END"; expect(extractSqlParameters(sql, { databaseType: "saphana" })).toEqual([]); diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index c180e62cf..cf94fc083 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -21,6 +21,8 @@ interface ParameterOccurrence extends SqlParameterDescriptor { end: number; } +type ComplexTypeDeclarationKind = "struct" | "variant"; + export interface SqlParameterOptions { databaseType?: DatabaseType; // Which placeholder syntaxes are recognized. Undefined enables all of them. @@ -82,6 +84,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) const supportsNamedParameters = options?.databaseType !== "saphana"; const enabledSyntaxes = options?.enabledSyntaxes ? new Set(options.enabledSyntaxes) : null; const isSyntaxEnabled = (syntax: SqlParameterSyntax) => !enabledSyntaxes || enabledSyntaxes.has(syntax); + const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql) : new Set(); let i = 0; let dollarQuoteEnd = ""; let positionalIndex = 0; @@ -123,7 +126,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) } if (ch === ":" && supportsNamedParameters && isSyntaxEnabled("named")) { const name = readParameterName(sql, i + 1); - if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=") { + if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=" && !complexTypeFieldSeparators.has(i)) { occurrences.push({ key: name, name, @@ -191,6 +194,198 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) return occurrences; } +// Doris-style complex types use colons between field names and types; those are not bind parameters. +function collectComplexTypeFieldSeparators(sql: string): Set { + const separators = new Set(); + let i = 0; + let dollarQuoteEnd = ""; + + while (i < sql.length) { + if (dollarQuoteEnd) { + const end = sql.indexOf(dollarQuoteEnd, i); + if (end === -1) break; + i = end + dollarQuoteEnd.length; + dollarQuoteEnd = ""; + continue; + } + + const ch = sql[i]; + const next = sql[i + 1]; + if (ch === "'" || ch === '"' || ch === "`") { + i = skipQuoted(sql, i, ch); + continue; + } + if (ch === "[") { + i = skipBracketIdentifier(sql, i); + continue; + } + if (ch === "-" && next === "-") { + i = skipLine(sql, i + 2); + continue; + } + if (ch === "/" && next === "*") { + i = skipBlockComment(sql, i + 2); + continue; + } + if (isHashLineComment(sql, i)) { + i = skipLine(sql, i + 1); + continue; + } + if (ch === "$") { + const marker = readDollarQuoteMarker(sql, i); + if (marker) { + dollarQuoteEnd = marker; + i += marker.length; + continue; + } + } + const declaration = readComplexTypeDeclaration(sql, i); + if (declaration) { + i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators) + 1; + continue; + } + i += 1; + } + + return separators; +} + +function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: number, kind: ComplexTypeDeclarationKind, separators: Set): number { + let i = start; + let genericDepth = 0; + let parenthesisDepth = 0; + let expectsFieldName = true; + + while (i < sql.length) { + if (expectsFieldName && genericDepth === 0 && parenthesisDepth === 0) { + const fieldStart = skipSqlWhitespaceAndComments(sql, i); + if (fieldStart !== i) { + i = fieldStart; + continue; + } + if (isLineStatementStart(sql, i) && isSqlStatementKeyword(sql, i)) return i; + const fieldNameEnd = readComplexTypeFieldNameEnd(sql, i, kind); + if (fieldNameEnd > i) { + const separator = skipSqlWhitespaceAndComments(sql, fieldNameEnd); + if (sql[separator] === ":") { + separators.add(separator); + i = separator + 1; + expectsFieldName = false; + continue; + } + i = fieldNameEnd; + expectsFieldName = false; + continue; + } + } + + const ch = sql[i]; + const next = sql[i + 1]; + if (ch === "'" || ch === '"' || ch === "`") { + i = skipQuoted(sql, i, ch); + continue; + } + if (ch === "[") { + i = skipBracketIdentifier(sql, i); + continue; + } + if (ch === "-" && next === "-") { + i = skipLine(sql, i + 2); + continue; + } + if (ch === "/" && next === "*") { + i = skipBlockComment(sql, i + 2); + continue; + } + if (isHashLineComment(sql, i)) { + i = skipLine(sql, i + 1); + continue; + } + const declaration = readComplexTypeDeclaration(sql, i); + if (declaration) { + i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators) + 1; + continue; + } + if (ch === ";" && genericDepth === 0 && parenthesisDepth === 0) return i; + if (ch === "<") { + genericDepth += 1; + i += 1; + continue; + } + if (ch === ">") { + if (genericDepth === 0 && parenthesisDepth === 0) return i; + if (genericDepth > 0) genericDepth -= 1; + i += 1; + continue; + } + if (ch === "(") { + parenthesisDepth += 1; + i += 1; + continue; + } + if (ch === ")") { + if (parenthesisDepth > 0) parenthesisDepth -= 1; + i += 1; + continue; + } + if (ch === "," && genericDepth === 0 && parenthesisDepth === 0) { + expectsFieldName = true; + } + i += 1; + } + + return sql.length; +} + +function readComplexTypeDeclaration(sql: string, start: number): { kind: ComplexTypeDeclarationKind; openingBracket: number } | null { + const kind: ComplexTypeDeclarationKind | null = matchesWord(sql, start, "struct") ? "struct" : matchesWord(sql, start, "variant") ? "variant" : null; + if (!kind) return null; + + const openingBracket = skipSqlWhitespaceAndComments(sql, start + kind.length); + return sql[openingBracket] === "<" ? { kind, openingBracket } : null; +} + +function readComplexTypeFieldNameEnd(sql: string, start: number, kind: ComplexTypeDeclarationKind): number { + if (kind === "variant") return readVariantFieldNameEnd(sql, start); + + const ch = sql[start]; + if (ch === '"' || ch === "`") return skipQuoted(sql, start, ch); + if (ch === "[") return skipBracketIdentifier(sql, start); + if (!PARAMETER_NAME_START_RE.test(ch ?? "")) return start; + + let i = start + 1; + while (i < sql.length && PARAMETER_NAME_CHAR_RE.test(sql[i])) i += 1; + return i; +} + +function readVariantFieldNameEnd(sql: string, start: number): number { + let i = start; + const modifier = matchesWord(sql, i, "match_name") ? "match_name" : matchesWord(sql, i, "match_name_glob") ? "match_name_glob" : ""; + if (modifier) i = skipSqlWhitespaceAndComments(sql, i + modifier.length); + return sql[i] === "'" ? skipQuoted(sql, i, "'") : start; +} + +function skipSqlWhitespaceAndComments(sql: string, start: number): number { + let i = start; + while (i < sql.length) { + while (i < sql.length && /\s/.test(sql[i])) i += 1; + if (sql[i] === "-" && sql[i + 1] === "-") { + i = skipLine(sql, i + 2); + continue; + } + if (sql[i] === "/" && sql[i + 1] === "*") { + i = skipBlockComment(sql, i + 2); + continue; + } + if (isHashLineComment(sql, i)) { + i = skipLine(sql, i + 1); + continue; + } + break; + } + return i; +} + function collectNativeSqlServerParameters(sql: string): { declared: Set; ignoredStarts: Set } { const declared = new Set(); const ignoredStarts = new Set();