fix(sql): preserve Doris complex type field separators

This commit is contained in:
onenewcode
2026-07-14 14:34:26 +08:00
committed by GitHub
parent aff9c8515d
commit a9baa8b220
3 changed files with 316 additions and 1 deletions
@@ -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<struct<field1:smallint, field2:int, field3:decimal(16,5), field4:varchar(255)>> comment 'field list'
)
engine = olap
properties ("replication_num" = "1");
`;
const activeTab = ref<QueryTab | undefined>(queryTab("analytics"));
const activeConnection = ref<ConnectionConfig | undefined>(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 | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
@@ -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<struct<field1:smallint, field2:int, field3:decimal(16,5), field4:varchar(255)>> 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<struct<
field1:smallint,
nested:struct<\`field2\` /* field type */ :decimal(:precision, :scale)>
>>
) 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<struct<
field1:smallint,
nested:struct<\`field2\` /* field type */ :decimal(16, 5)>
>>
) properties ("buckets" = 8);
`);
});
it("does not let an unterminated complex type hide a later named parameter", () => {
const sql = "create table `broken` (value struct<field:int,\nselect :real;";
expect(extractSqlParameters(sql, { databaseType: "doris" })).toEqual(["real"]);
expect(substituteSqlParameters(sql, { real: { kind: "number", value: "7" } }, { databaseType: "doris" })).toBe("create table `broken` (value struct<field:int,\nselect 7;");
});
it("ignores Doris VARIANT field type separators", () => {
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([]);
+196 -1
View File
@@ -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<number>();
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<number> {
const separators = new Set<number>();
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>): 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<string>; ignoredStarts: Set<number> } {
const declared = new Set<string>();
const ignoredStarts = new Set<number>();