From f274a599ed8fdac00a01c1d1dfd35fbf5372965a Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sat, 22 Aug 2026 14:01:46 +0800 Subject: [PATCH] fix(sql): respect PostgreSQL ARRAY brackets --- .../__tests__/useSqlExecution.spec.ts | 60 ++++++++ .../src/composables/useSqlExecution.ts | 3 +- .../lib/__tests__/sql/sqlParameters.spec.ts | 88 ++++++++++++ .../lib/__tests__/sql/sqlVariables.spec.ts | 16 +++ apps/desktop/src/lib/sql/sqlParameters.ts | 130 ++++++++++++------ apps/desktop/src/lib/sql/sqlVariables.ts | 42 ++++-- 6 files changed, 282 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index c4f2cb6b20..eb4e5c809a 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -628,6 +628,66 @@ SELECT @value AS Message;`; expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { openInNewResultTab: true }); }); + it("executes the PostgreSQL ARRAY date-format query without opening the parameter dialog", async () => { + const sql = ` + WITH rec_flow AS ( + SELECT + order_no, + ARRAY[ + concat( + operator_name, '(', COALESCE(remark, ''), ')[', + CASE operate_action + WHEN 'CREATE' THEN '创建工单' + WHEN 'SUBMIT' THEN '提交至下一处理人' + WHEN 'BACK' THEN '退回上一环节' + WHEN 'FINISH' THEN '已完成' + WHEN 'SUBMIT-CONFIRM' THEN '提交给创建人确认' + WHEN 'COMPLETE' THEN '确认工单' + ELSE operate_action + END, ']' + ) + ]::varchar[] + || CASE + WHEN operate_action NOT IN ('FINISH','COMPLETE') + THEN ARRAY[target_handler_name]::varchar[] + ELSE ARRAY[]::varchar[] + END AS name_arr, + rn + FROM ( + SELECT + 'ORD20260821001' AS order_no, + '张三' AS operator_name, + '发起流程' AS remark, + 'SUBMIT' AS operate_action, + '李四' AS target_handler_name, + 1 AS rn + ) AS mock_t3_flow + ) + SELECT to_char(current_timestamp, 'yyyy-MM-dd HH24:mi:ss'); + `; + const activeTab = ref(queryTab("app")); + const activeConnection = ref(connection("postgres")); + 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: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }; + }); + vi.spyOn(useHistoryStore(), "add").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("executes Oracle database-link queries without opening the parameter dialog", async () => { const sql = "SELECT 1 FROM DUAL@WDHIS160;"; const activeTab = ref(queryTab("ORCL")); diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index d494a3d3d3..b31239501b 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -144,7 +144,8 @@ export function useSqlExecution(deps: { async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> { const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet; - const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql }).sql : sql); + const databaseType = effectiveDatabaseTypeForConnection(deps.activeConnection.value) ?? deps.activeConnection.value?.db_type; + const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql, databaseType }).sql : sql); if (typeof source === "string") return { sql: expand(source) }; const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value; diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index 940fa6791f..f1807201e9 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -116,6 +116,94 @@ describe("extractSqlParameters", () => { expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["?1", "?2", "?3", "?4", "?5", "?6"]); }); + it("does not expose date format tokens after PostgreSQL ARRAY expressions", () => { + const sql = ` + WITH rec_flow AS ( + SELECT + order_no, + ARRAY[ + concat( + operator_name, '(', COALESCE(remark, ''), ')[', + CASE operate_action + WHEN 'CREATE' THEN '创建工单' + WHEN 'SUBMIT' THEN '提交至下一处理人' + WHEN 'BACK' THEN '退回上一环节' + WHEN 'FINISH' THEN '已完成' + WHEN 'SUBMIT-CONFIRM' THEN '提交给创建人确认' + WHEN 'COMPLETE' THEN '确认工单' + ELSE operate_action + END, ']' + ) + ]::varchar[] + || CASE + WHEN operate_action NOT IN ('FINISH','COMPLETE') + THEN ARRAY[target_handler_name]::varchar[] + ELSE ARRAY[]::varchar[] + END AS name_arr, + rn + FROM ( + SELECT + 'ORD20260821001' AS order_no, + '张三' AS operator_name, + '发起流程' AS remark, + 'SUBMIT' AS operate_action, + '李四' AS target_handler_name, + 1 AS rn + ) AS mock_t3_flow + ) + SELECT to_char(current_timestamp, 'yyyy-MM-dd HH24:mi:ss'); + `; + + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual([]); + }); + + it("keeps PostgreSQL ARRAY literals and subscripts in the lexical stream", () => { + const dateSql = "to_char(current_timestamp, 'yyyy-MM-dd HH24:mi:ss')"; + for (const arrayExpression of ["ARRAY['x']", "ARRAY[']']", "ARRAY['[']", "ARRAY['a]b']", "ARRAY[]::varchar[]"]) { + expect(extractSqlParameters(`SELECT ${arrayExpression}, ${dateSql};`, { databaseType: "postgres" })).toEqual([]); + } + + expect(extractSqlParameters("SELECT ARRAY['x'][:array_index], values[:subscript_index];", { databaseType: "postgres" })).toEqual([]); + }); + + it("does not treat a standalone PostgreSQL date format as a parameter", () => { + expect(extractSqlParameters("SELECT to_char(current_timestamp, 'HH24:MI:SS');", { databaseType: "postgres" })).toEqual([]); + }); + + it("keeps PostgreSQL named parameters inside ARRAY constructors", () => { + const sql = "SELECT ARRAY[:first_value, :second_value];"; + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["first_value", "second_value"]); + expect(substituteSqlParameters(sql, { first_value: { kind: "number", value: "1" }, second_value: { kind: "number", value: "2" } }, { databaseType: "postgres" })).toBe("SELECT ARRAY[1, 2];"); + expect(extractSqlParameters("SELECT :id;", { databaseType: "postgres" })).toEqual(["id"]); + }); + + it("does not treat PostgreSQL slice bounds as named parameters", () => { + const sql = "SELECT arr[lower:upper], arr[:upper], ARRAY[arr[:nested_upper], :constructor_value];"; + + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["constructor_value"]); + expect(substituteSqlParameters(sql, { constructor_value: { kind: "number", value: "7" } }, { databaseType: "postgres" })).toBe("SELECT arr[lower:upper], arr[:upper], ARRAY[arr[:nested_upper], 7];"); + }); + + it("keeps named parameters inside nested PostgreSQL ARRAY constructors and parenthesized subscripts", () => { + const sql = "SELECT ARRAY[[:first_value, :second_value], ARRAY[:third_value]], values[(:subscript_index)];"; + + expect(extractSqlParameters(sql, { databaseType: "postgres" })).toEqual(["first_value", "second_value", "third_value", "subscript_index"]); + }); + + it("preserves SQL Server bracketed identifiers while scanning parameters", () => { + const sql = "SELECT [column:inside], :actual"; + expect(extractSqlParameters(sql, { databaseType: "sqlserver" })).toEqual(["actual"]); + expect(substituteSqlParameters(sql, { actual: { kind: "number", value: "7" } }, { databaseType: "sqlserver" })).toBe("SELECT [column:inside], 7"); + }); + + it.each(["sqlite", "jdbc", "access"] as const)("preserves bracketed identifiers for %s", (databaseType) => { + expect(extractSqlParameters("SELECT [column:inside], :actual", { databaseType })).toEqual(["actual"]); + }); + + it("keeps historical bracket scanning when no database dialect is supplied", () => { + expect(extractSqlParameters("SELECT [column:inside], :actual")).toEqual(["actual"]); + }); + it("keeps question marks as positional placeholders for other databases", () => { expect(extractSqlParameters("select payload ? 'callingResults' from events", { databaseType: "mysql" })).toEqual(["?1"]); }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlVariables.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlVariables.spec.ts index d77d90ef2f..e1cbf3c399 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlVariables.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlVariables.spec.ts @@ -60,6 +60,22 @@ describe("expandSqlVariables", () => { expect(expandSqlVariables(sql)).toEqual({ sql, expanded: false }); }); + it("keeps PostgreSQL ARRAY literals from corrupting later variable references", () => { + const sql = ["@set selected = 7;", "select ARRAY[']']::varchar[], @selected, ${missing}"].join("\n"); + expect(expandSqlVariables(sql, { databaseType: "postgres" }).sql).toBe("select ARRAY[']']::varchar[], 7, ${missing}"); + }); + + it("keeps multiline PostgreSQL ARRAY values in @set declarations", () => { + const sql = ["@set values = ARRAY[", " 'first',", " 'second'", "];", "select @values;"].join("\n"); + + expect(expandSqlVariables(sql, { databaseType: "postgres" }).sql).toBe("select ARRAY[\n 'first',\n 'second'\n];"); + }); + + it.each(["sqlserver", "sqlite", "jdbc"] as const)("preserves %s bracket identifiers during variable expansion", (databaseType) => { + const sql = ["@set selected = 7;", "select [column:@ignored], @selected"].join("\n"); + expect(expandSqlVariables(sql, { databaseType }).sql).toBe("select [column:@ignored], 7"); + }); + it("keeps a value's own quotes and parentheses intact", () => { const sql = ["@set filter = (status = 'drafted' and deleted_at is null);", "select * from t where @filter"].join("\n"); expect(expandSqlVariables(sql).sql).toBe("select * from t where (status = 'drafted' and deleted_at is null)"); diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index c73669ad9a..aeb6297451 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -227,7 +227,7 @@ export function substituteSqlParameters(sql: string, values: Record !enabledSyntaxes || enabledSyntaxes.has(syntax); - const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql) : new Set(); - const duckDbStructFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") && options?.databaseType === "duckdb" ? collectDuckDbStructFieldSeparators(sql) : new Set(); - const triggerPseudoRecordFieldStarts = supportsNamedParameters && isSyntaxEnabled("named") ? collectTriggerPseudoRecordFieldStarts(sql, options?.databaseType) : new Set(); + const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql, databaseType) : new Set(); + const duckDbStructFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") && databaseType === "duckdb" ? collectDuckDbStructFieldSeparators(sql) : new Set(); + const triggerPseudoRecordFieldStarts = supportsNamedParameters && isSyntaxEnabled("named") ? collectTriggerPseudoRecordFieldStarts(sql, databaseType) : new Set(); let i = 0; let dollarQuoteEnd = ""; let positionalIndex = 0; + let parenthesisDepth = 0; + const postgresBracketStack: Array<{ constructor: boolean; parenthesisDepth: number }> = []; while (i < sql.length) { if (dollarQuoteEnd) { @@ -391,7 +394,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) const next = sql[i + 1]; if (ch === "<" && isSyntaxEnabled("mybatis")) { - const foreach = readMyBatisForeachAt(sql, i); + const foreach = readMyBatisForeachAt(sql, i, databaseType); if (foreach) { occurrences.push({ key: foreach.collection, @@ -446,8 +449,31 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) i = skipQuoted(sql, i, ch); continue; } + if (databaseType === "postgres" && ch === "(") { + parenthesisDepth += 1; + i += 1; + continue; + } + if (databaseType === "postgres" && ch === ")") { + parenthesisDepth = Math.max(0, parenthesisDepth - 1); + i += 1; + continue; + } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + if (databaseType === "postgres") { + postgresBracketStack.push({ + constructor: isPostgresArrayConstructorBracket(sql, i, postgresBracketStack[postgresBracketStack.length - 1]?.constructor ?? false), + parenthesisDepth, + }); + i += 1; + continue; + } + i = skipBracketIdentifier(sql, i, databaseType); + continue; + } + if (databaseType === "postgres" && ch === "]") { + postgresBracketStack.pop(); + i += 1; continue; } if (ch === "-" && next === "-") { @@ -477,6 +503,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) sql[i + 1] !== "=" && !complexTypeFieldSeparators.has(i) && !duckDbStructFieldSeparators.has(i) && + !isPostgresSliceSeparator(postgresBracketStack, parenthesisDepth) && !isDuckDbCompactPrefixAliasSeparator(sql, i, options?.databaseType) && !triggerPseudoRecordFieldStarts.has(i) && !isMysqlRoutineLabelSeparator(sql, i, name, options?.databaseType) @@ -544,7 +571,7 @@ function isMysqlRoutineLabelSeparator(sql: string, colonIndex: number, following return prefix.length === 0 || prefix.endsWith(";") || MYSQL_ROUTINE_LABEL_CONTEXTS.has(previousKeyword(sql, labelStart)); } -function readMyBatisForeachAt(sql: string, start: number): { collection: string; render: MyBatisForeach; end: number } | null { +function readMyBatisForeachAt(sql: string, start: number, databaseType?: DatabaseType): { collection: string; render: MyBatisForeach; end: number } | null { if (!/^)/i.test(sql.slice(start))) return null; const openingEnd = findXmlTagEnd(sql, start); if (openingEnd === -1) return null; @@ -556,7 +583,7 @@ function readMyBatisForeachAt(sql: string, start: number): { collection: string; const index = attributes.get("index")?.trim(); if (!PARAMETER_NAME_RE.test(collection) || !PARAMETER_NAME_RE.test(item) || (index !== undefined && !PARAMETER_NAME_RE.test(index))) return null; - const close = findMatchingXmlTagClose(sql, openingEnd + 1, "foreach"); + const close = findMatchingXmlTagClose(sql, openingEnd + 1, "foreach", databaseType); if (!close) return null; return { collection, @@ -624,7 +651,7 @@ function decodeXmlAttribute(value: string): string { return value.replace(/&(?:lt|gt|amp|quot|apos);/g, (entity) => ({ "<": "<", ">": ">", "&": "&", """: '"', "'": "'" })[entity] ?? entity); } -function findMatchingXmlTagClose(sql: string, start: number, tagName: string): { start: number; end: number } | null { +function findMatchingXmlTagClose(sql: string, start: number, tagName: string, databaseType?: DatabaseType): { start: number; end: number } | null { let depth = 1; let i = start; @@ -637,7 +664,7 @@ function findMatchingXmlTagClose(sql: string, start: number, tagName: string): { continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -865,7 +892,7 @@ function collectTriggerPseudoRecordFieldStarts(sql: string, databaseType?: Datab continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -998,7 +1025,7 @@ function isJdbcxMcpScopedPackage(sql: string, start: number, nameEnd: number): b } // Doris-style complex types use colons between field names and types; those are not bind parameters. -function collectComplexTypeFieldSeparators(sql: string): Set { +function collectComplexTypeFieldSeparators(sql: string, databaseType?: DatabaseType): Set { const separators = new Set(); let i = 0; let dollarQuoteEnd = ""; @@ -1019,7 +1046,7 @@ function collectComplexTypeFieldSeparators(sql: string): Set { continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1044,7 +1071,7 @@ function collectComplexTypeFieldSeparators(sql: string): Set { } const declaration = readComplexTypeDeclaration(sql, i); if (declaration) { - i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators) + 1; + i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators, databaseType) + 1; continue; } i += 1; @@ -1053,7 +1080,7 @@ function collectComplexTypeFieldSeparators(sql: string): Set { return separators; } -function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: number, kind: ComplexTypeDeclarationKind, separators: Set): number { +function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: number, kind: ComplexTypeDeclarationKind, separators: Set, databaseType?: DatabaseType): number { let i = start; let genericDepth = 0; let parenthesisDepth = 0; @@ -1067,7 +1094,7 @@ function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: numb continue; } if (isLineStatementStart(sql, i) && isSqlStatementKeyword(sql, i)) return i; - const fieldNameEnd = readComplexTypeFieldNameEnd(sql, i, kind); + const fieldNameEnd = readComplexTypeFieldNameEnd(sql, i, kind, databaseType); if (fieldNameEnd > i) { const separator = skipSqlWhitespaceAndComments(sql, fieldNameEnd); if (sql[separator] === ":") { @@ -1089,7 +1116,7 @@ function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: numb continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1106,7 +1133,7 @@ function collectComplexTypeFieldSeparatorsInDeclaration(sql: string, start: numb } const declaration = readComplexTypeDeclaration(sql, i); if (declaration) { - i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators) + 1; + i = collectComplexTypeFieldSeparatorsInDeclaration(sql, declaration.openingBracket + 1, declaration.kind, separators, databaseType) + 1; continue; } if (ch === ";" && genericDepth === 0 && parenthesisDepth === 0) return i; @@ -1148,12 +1175,12 @@ function readComplexTypeDeclaration(sql: string, start: number): { kind: Complex return sql[openingBracket] === "<" ? { kind, openingBracket } : null; } -function readComplexTypeFieldNameEnd(sql: string, start: number, kind: ComplexTypeDeclarationKind): number { +function readComplexTypeFieldNameEnd(sql: string, start: number, kind: ComplexTypeDeclarationKind, databaseType?: DatabaseType): 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 (ch === "[") return skipBracketIdentifier(sql, start, databaseType); if (!PARAMETER_NAME_START_RE.test(ch ?? "")) return start; let i = start + 1; @@ -1189,7 +1216,7 @@ function skipSqlWhitespaceAndComments(sql: string, start: number): number { return i; } -function collectNativeSqlServerParameters(sql: string): { declared: Set; ignoredStarts: Set } { +function collectNativeSqlServerParameters(sql: string, databaseType?: DatabaseType): { declared: Set; ignoredStarts: Set } { const declared = new Set(); const ignoredStarts = new Set(); let i = 0; @@ -1211,7 +1238,7 @@ function collectNativeSqlServerParameters(sql: string): { declared: Set; continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1235,23 +1262,23 @@ function collectNativeSqlServerParameters(sql: string): { declared: Set; continue; } if (matchesWord(sql, i, "declare")) { - i = collectDeclareStatementVariables(sql, i + "declare".length, declared); + i = collectDeclareStatementVariables(sql, i + "declare".length, declared, databaseType); continue; } if (matchesWord(sql, i, "set")) { - i = collectSetStatementVariables(sql, i + "set".length, declared); + i = collectSetStatementVariables(sql, i + "set".length, declared, databaseType); continue; } if (matchesWord(sql, i, "select")) { - i = collectSelectAssignmentVariables(sql, i + "select".length, declared); + i = collectSelectAssignmentVariables(sql, i + "select".length, declared, databaseType); continue; } if ((matchesWord(sql, i, "create") || matchesWord(sql, i, "alter")) && isRoutineDefinitionStart(sql, i)) { - i = collectRoutineDefinitionVariables(sql, i, declared); + i = collectRoutineDefinitionVariables(sql, i, declared, databaseType); continue; } if (matchesWord(sql, i, "exec") || matchesWord(sql, i, "execute")) { - i = collectExecNamedArgumentStarts(sql, i + (matchesWord(sql, i, "exec") ? "exec".length : "execute".length), ignoredStarts); + i = collectExecNamedArgumentStarts(sql, i + (matchesWord(sql, i, "exec") ? "exec".length : "execute".length), ignoredStarts, databaseType); continue; } i += 1; @@ -1260,7 +1287,7 @@ function collectNativeSqlServerParameters(sql: string): { declared: Set; return { declared, ignoredStarts }; } -function collectDeclareStatementVariables(sql: string, start: number, declared: Set): number { +function collectDeclareStatementVariables(sql: string, start: number, declared: Set, databaseType?: DatabaseType): number { let i = start; while (i < sql.length) { const ch = sql[i]; @@ -1272,7 +1299,7 @@ function collectDeclareStatementVariables(sql: string, start: number, declared: continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1300,7 +1327,7 @@ function collectDeclareStatementVariables(sql: string, start: number, declared: return i; } -function collectSetStatementVariables(sql: string, start: number, declared: Set): number { +function collectSetStatementVariables(sql: string, start: number, declared: Set, databaseType?: DatabaseType): number { let i = start; while (i < sql.length) { const ch = sql[i]; @@ -1312,7 +1339,7 @@ function collectSetStatementVariables(sql: string, start: number, declared: Set< continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1340,7 +1367,7 @@ function collectSetStatementVariables(sql: string, start: number, declared: Set< return i; } -function collectSelectAssignmentVariables(sql: string, start: number, declared: Set): number { +function collectSelectAssignmentVariables(sql: string, start: number, declared: Set, databaseType?: DatabaseType): number { let i = start; while (i < sql.length) { const ch = sql[i]; @@ -1353,7 +1380,7 @@ function collectSelectAssignmentVariables(sql: string, start: number, declared: continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1381,7 +1408,7 @@ function collectSelectAssignmentVariables(sql: string, start: number, declared: return i; } -function collectRoutineDefinitionVariables(sql: string, start: number, declared: Set): number { +function collectRoutineDefinitionVariables(sql: string, start: number, declared: Set, databaseType?: DatabaseType): number { let i = start; while (i < sql.length) { const ch = sql[i]; @@ -1393,7 +1420,7 @@ function collectRoutineDefinitionVariables(sql: string, start: number, declared: continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1421,7 +1448,7 @@ function collectRoutineDefinitionVariables(sql: string, start: number, declared: return i; } -function collectExecNamedArgumentStarts(sql: string, start: number, ignoredStarts: Set): number { +function collectExecNamedArgumentStarts(sql: string, start: number, ignoredStarts: Set, databaseType?: DatabaseType): number { let i = start; while (i < sql.length) { const ch = sql[i]; @@ -1433,7 +1460,7 @@ function collectExecNamedArgumentStarts(sql: string, start: number, ignoredStart continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -1636,7 +1663,12 @@ function skipQuoted(sql: string, start: number, quote: string): number { return sql.length; } -function skipBracketIdentifier(sql: string, start: number): number { +function skipBracketIdentifier(sql: string, start: number, databaseType?: DatabaseType): number { + // PostgreSQL uses square brackets for ARRAY constructors and subscripts, not + // for delimited identifiers. Leave the opening bracket in the lexical stream + // so quoted array elements cannot corrupt the following SQL state. + if (databaseType === "postgres") return start + 1; + let i = start + 1; while (i < sql.length) { if (sql[i] === "]") { @@ -1676,6 +1708,20 @@ function isSqlServerTempTableReference(sql: string, start: number): boolean { return !!previous && SQL_SERVER_TEMP_TABLE_CONTEXT_KEYWORDS.has(previous); } +function isPostgresArrayConstructorBracket(sql: string, start: number, parentIsConstructor: boolean): boolean { + if (previousKeyword(sql, start) === "array") return true; + if (!parentIsConstructor) return false; + + let previous = start - 1; + while (previous >= 0 && /\s/.test(sql[previous])) previous -= 1; + return sql[previous] === "[" || sql[previous] === ","; +} + +function isPostgresSliceSeparator(stack: Array<{ constructor: boolean; parenthesisDepth: number }>, parenthesisDepth: number): boolean { + const context = stack[stack.length - 1]; + return !!context && !context.constructor && context.parenthesisDepth === parenthesisDepth; +} + function previousKeyword(sql: string, start: number): string { let end = start - 1; while (end >= 0 && /\s/.test(sql[end])) end -= 1; diff --git a/apps/desktop/src/lib/sql/sqlVariables.ts b/apps/desktop/src/lib/sql/sqlVariables.ts index 0e5fc9bd83..2fb0d30a0b 100644 --- a/apps/desktop/src/lib/sql/sqlVariables.ts +++ b/apps/desktop/src/lib/sql/sqlVariables.ts @@ -11,6 +11,8 @@ // reference is only expanded when a matching `@set` declaration exists; any // unresolved `${name}` or `@name` remains available to the parameter dialog. +import type { DatabaseType } from "@/types/database"; + const VARIABLE_NAME_START_RE = /[\p{L}_]/u; const VARIABLE_NAME_CHAR_RE = /[\p{L}\p{N}_]/u; @@ -21,6 +23,7 @@ export interface SqlVariableExpansion { export interface SqlVariableExpansionOptions { declarationSql?: string; + databaseType?: DatabaseType; } interface DeclarationSpan { @@ -39,7 +42,7 @@ interface DeclarationSpan { */ export function expandSqlVariables(sql: string, options: SqlVariableExpansionOptions = {}): SqlVariableExpansion { const declarationSql = options.declarationSql ?? sql; - const declarations = collectDeclarations(declarationSql); + const declarations = collectDeclarations(declarationSql, options.databaseType); if (!declarations.length) return { sql, expanded: false }; const values = new Map(); @@ -49,18 +52,18 @@ export function expandSqlVariables(sql: string, options: SqlVariableExpansionOpt // Only spans inside the execution target are removed. Declarations supplied // by the surrounding document are context and must not affect target offsets. - const targetDeclarations = declarationSql === sql ? declarations : collectDeclarations(sql); + const targetDeclarations = declarationSql === sql ? declarations : collectDeclarations(sql, options.databaseType); let result = sql; for (let i = targetDeclarations.length - 1; i >= 0; i -= 1) { const declaration = targetDeclarations[i]; result = stripDeclaration(result, declaration.start, declaration.end); } - result = replaceReferences(result, values); + result = replaceReferences(result, values, options.databaseType); return { sql: result, expanded: result !== sql }; } -function collectDeclarations(sql: string): DeclarationSpan[] { +function collectDeclarations(sql: string, databaseType?: DatabaseType): DeclarationSpan[] { const declarations: DeclarationSpan[] = []; let i = 0; let dollarQuoteEnd = ""; @@ -82,7 +85,7 @@ function collectDeclarations(sql: string): DeclarationSpan[] { continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + i = skipBracketIdentifier(sql, i, databaseType); continue; } if (ch === "-" && next === "-") { @@ -102,7 +105,7 @@ function collectDeclarations(sql: string): DeclarationSpan[] { } } if (ch === "@" && matchesWord(sql, i + 1, "set") && isStatementStart(sql, i)) { - const declaration = readDeclaration(sql, i); + const declaration = readDeclaration(sql, i, databaseType); if (declaration) { declarations.push(declaration); i = declaration.end; @@ -118,7 +121,7 @@ function collectDeclarations(sql: string): DeclarationSpan[] { // Parse `@set name = value` starting at `start` (the `@`). The value runs until // the terminating `;` or end of input, honouring nested quotes, comments and // parentheses so that `IN (...)` lists and quoted strings survive intact. -function readDeclaration(sql: string, start: number): DeclarationSpan | null { +function readDeclaration(sql: string, start: number, databaseType?: DatabaseType): DeclarationSpan | null { let i = start + 1 + "set".length; i = skipInlineWhitespace(sql, i); @@ -132,7 +135,7 @@ function readDeclaration(sql: string, start: number): DeclarationSpan | null { i = skipInlineWhitespace(sql, i); const valueStart = i; - const valueEnd = readValueEnd(sql, i); + const valueEnd = readValueEnd(sql, i, databaseType); const value = sql.slice(valueStart, valueEnd).trim(); if (!value) return null; @@ -143,9 +146,10 @@ function readDeclaration(sql: string, start: number): DeclarationSpan | null { return { name, value, start, end }; } -function readValueEnd(sql: string, start: number): number { +function readValueEnd(sql: string, start: number, databaseType?: DatabaseType): number { let i = start; let depth = 0; + let bracketDepth = 0; while (i < sql.length) { const ch = sql[i]; const next = sql[i + 1]; @@ -154,9 +158,15 @@ function readValueEnd(sql: string, start: number): number { continue; } if (ch === "[") { - i = skipBracketIdentifier(sql, i); + if (databaseType === "postgres") { + bracketDepth += 1; + i += 1; + continue; + } + i = skipBracketIdentifier(sql, i, databaseType); continue; } + if (databaseType === "postgres" && ch === "]") bracketDepth = Math.max(0, bracketDepth - 1); if (ch === "-" && next === "-") return i; if (ch === "/" && next === "*") return i; if (ch === "$") { @@ -169,13 +179,13 @@ function readValueEnd(sql: string, start: number): number { } if (ch === "(") depth += 1; else if (ch === ")") depth = Math.max(0, depth - 1); - else if ((ch === ";" || ch === "\n") && depth === 0) return i; + else if ((ch === ";" || ch === "\n") && depth === 0 && bracketDepth === 0) return i; i += 1; } return sql.length; } -function replaceReferences(sql: string, values: Map): string { +function replaceReferences(sql: string, values: Map, databaseType?: DatabaseType): string { let result = ""; let i = 0; let dollarQuoteEnd = ""; @@ -200,7 +210,7 @@ function replaceReferences(sql: string, values: Map): string { continue; } if (ch === "[") { - const end = skipBracketIdentifier(sql, i); + const end = skipBracketIdentifier(sql, i, databaseType); result += sql.slice(i, end); i = end; continue; @@ -317,7 +327,11 @@ function skipQuoted(sql: string, start: number, quote: string): number { return sql.length; } -function skipBracketIdentifier(sql: string, start: number): number { +function skipBracketIdentifier(sql: string, start: number, databaseType?: DatabaseType): number { + // PostgreSQL uses square brackets for ARRAY constructors and subscripts, not + // for delimited identifiers. Leave the opening bracket in the lexical stream. + if (databaseType === "postgres") return start + 1; + let i = start + 1; while (i < sql.length) { if (sql[i] === "]") {