From 9288717ac4a0a8b3dc8a7193772633cbca05f758 Mon Sep 17 00:00:00 2001 From: miracle Date: Tue, 7 Jul 2026 22:24:48 +0800 Subject: [PATCH] fix(sql): ignore native SQL variables --- .../__tests__/useSqlExecution.spec.ts | 71 ++++- .../lib/__tests__/sql/sqlParameters.spec.ts | 119 ++++++++ apps/desktop/src/lib/sql/sqlParameters.ts | 259 +++++++++++++++++- 3 files changed, 440 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index 1c2be86253..12581480bc 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -1,7 +1,30 @@ -import { describe, expect, it } from "vitest"; -import { requiresDatabaseSelection } from "../useSqlExecution"; +import { computed, ref } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requiresDatabaseSelection, useSqlExecution } from "../useSqlExecution"; +import { useHistoryStore } from "@/stores/historyStore"; +import { useQueryStore } from "@/stores/queryStore"; import type { ConnectionConfig, QueryTab } from "@/types/database"; +vi.mock("vue-i18n", () => ({ + createI18n: () => ({ global: { locale: { value: "en" }, setLocaleMessage: vi.fn() } }), + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/lib/backend/api", () => ({ + saveEditorSettings: vi.fn(), + saveHistory: vi.fn(), +})); + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + function connection(dbType: ConnectionConfig["db_type"]): ConnectionConfig { return { id: "conn-1", @@ -31,6 +54,11 @@ function queryTab(database = ""): QueryTab { } describe("requiresDatabaseSelection", () => { + beforeEach(() => { + installLocalStorage(); + setActivePinia(createPinia()); + }); + it("allows MySQL CREATE DATABASE to run without a selected database", () => { expect(requiresDatabaseSelection(queryTab(), connection("mysql"), "CREATE DATABASE app_db")).toBe(false); }); @@ -71,3 +99,42 @@ describe("requiresDatabaseSelection", () => { expect(requiresDatabaseSelection(queryTab(""), connection("postgres"), "SELECT * FROM public.users")).toBe(false); }); }); + +describe("useSqlExecution", () => { + beforeEach(() => { + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("sends native SET variables without client-side expansion", async () => { + const activeTab = ref(queryTab("app")); + const activeConnection = ref(connection("mysql")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const historyStore = useHistoryStore(); + 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(historyStore, "add").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed( + () => ` + set @date_start = '2026-07-04 00:00:00'; + + select * from sa_access_decision_log AS fp + where fp.create_at < @date_start; + `, + ), + activeOutputView, + }); + + await execution.tryExecute(); + + const executedSql = executeCurrentSql.mock.calls[0]?.[0] ?? ""; + expect(executedSql).toContain("set @date_start = '2026-07-04 00:00:00'"); + expect(executedSql).toContain("where fp.create_at < @date_start"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index 9ee979a65b..c748abafd6 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -12,6 +12,8 @@ describe("extractSqlParameters", () => { select '\${quoted}' as a, "\${identifier}" as b, \`\${mysql_identifier}\` -- \${line_comment} # \${hash_comment} + #\${hash_comment_without_space} + select 1 #comment \${inline_hash_comment} /* \${block_comment} */ from t where id = \${id} @@ -48,6 +50,103 @@ describe("extractSqlParameters", () => { expect(extractSqlParameters(sql)).toEqual(["input_value"]); }); + it("ignores variables assigned by SET statements", () => { + const sql = ` + set @date_start = '2026-07-04 00:00:00'; + select * from fin_pur_payment AS fp where fp.create_time < @date_start and fp.tenant_id = @tenant_id + `; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("ignores multiple variables assigned by SET statements", () => { + const sql = ` + set @date_start := '2026-07-01', @date_end = '2026-07-31'; + select * from orders where created_at between @date_start and @date_end and tenant_id = @tenant_id + `; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("ignores variables assigned by SELECT statements", () => { + const sql = ` + select @date_start := min(created_at), @date_end = max(created_at) from orders; + select * from orders where created_at between @date_start and @date_end and tenant_id = @tenant_id + `; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("ignores SQL Server procedure parameters declared in routine definitions", () => { + const sql = ` + create procedure dbo.search_orders + @date_start datetime, + @status nvarchar(20) = N'paid' + as + begin + select * from orders where created_at >= @date_start and status = @status and tenant_id = @tenant_id; + end + `; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("ignores SQL Server function parameters declared in routine definitions", () => { + const sql = ` + create function dbo.order_count(@date_start datetime, @status nvarchar(20)) + returns int + as + begin + return (select count(*) from orders where created_at >= @date_start and status = @status and tenant_id = @tenant_id); + end + `; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("keeps template parameters in non-routine CREATE statements", () => { + const sql = "create table #orders (tenant_id int default @tenant_id);"; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("ignores named stored procedure arguments while preserving template values", () => { + const sql = "exec dbo.search_orders @date_start = '2026-07-04', @status = @status_value, @tenant_id = @tenant_id"; + expect(extractSqlParameters(sql)).toEqual(["status_value", "tenant_id"]); + }); + + it("ignores declared SQL Server table variables", () => { + const sql = ` + declare @ids table (id int); + insert into @ids values (1); + select * from @ids where id = @input_id; + `; + expect(extractSqlParameters(sql)).toEqual(["input_id"]); + }); + + it("ignores SQL Server and MySQL system variables", () => { + const sql = "select @@ROWCOUNT, @@IDENTITY, @@SERVERNAME, @@session.sql_mode, @@global.time_zone, @input_value"; + expect(extractSqlParameters(sql)).toEqual(["input_value"]); + }); + + it("extracts template parameters from ordinary SELECT filters", () => { + const sql = ` + select * from fin_pur_payment + where tenant_id = @tenant_id; + `; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("extracts remaining template parameters after SET-defined variables", () => { + const sql = ` + set @date_start = '2026-07-04 00:00:00'; + + select * from fin_pur_payment + where create_time < @date_start + and tenant_id = @tenant_id; + `; + expect(extractSqlParameters(sql)).toEqual(["tenant_id"]); + }); + + it("does not treat native variable updates as template parameters", () => { + const sql = "set @n = 1; set @n = @n + 1; select @n;"; + expect(extractSqlParameters(sql)).toEqual([]); + }); + it("stops SQL Server declaration scanning when a new statement starts without a semicolon", () => { const sql = ` declare @id int = 1 @@ -113,6 +212,26 @@ describe("substituteSqlParameters", () => { const sql = "DECLARE @id int = 1; SELECT * FROM users WHERE id = @id AND tenant_id = @tenant_id"; expect(substituteSqlParameters(sql, { tenant_id: { kind: "number", value: "7" } })).toBe("DECLARE @id int = 1; SELECT * FROM users WHERE id = @id AND tenant_id = 7"); }); + + it("leaves variables assigned by SET statements untouched while replacing undeclared variables", () => { + const sql = "set @date_start = '2026-07-04 00:00:00'; select * from fin_pur_payment where create_time < @date_start and tenant_id = @tenant_id"; + expect(substituteSqlParameters(sql, { tenant_id: { kind: "number", value: "7" } })).toBe("set @date_start = '2026-07-04 00:00:00'; select * from fin_pur_payment where create_time < @date_start and tenant_id = 7"); + }); + + it("preserves native variable updates instead of rewriting SQL text", () => { + const sql = "set @n = 1; set @n = @n + 1; select @n;"; + expect(substituteSqlParameters(sql, {})).toBe(sql); + }); + + it("leaves named stored procedure arguments untouched while replacing their template values", () => { + const sql = "exec dbo.search_orders @date_start = '2026-07-04', @status = @status_value, @tenant_id = @tenant_id"; + expect( + substituteSqlParameters(sql, { + status_value: { kind: "string", value: "paid" }, + tenant_id: { kind: "number", value: "7" }, + }), + ).toBe("exec dbo.search_orders @date_start = '2026-07-04', @status = 'paid', @tenant_id = 7"); + }); }); describe("sqlParameterLiteral", () => { diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index f718ede487..1cd61b1306 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -22,6 +22,7 @@ interface ParameterOccurrence extends SqlParameterDescriptor { const PARAMETER_NAME_RE = /^[\p{L}_][\p{L}\p{N}_]*$/u; const PARAMETER_NAME_START_RE = /[\p{L}_]/u; const PARAMETER_NAME_CHAR_RE = /[\p{L}\p{N}_]/u; +const SQL_SERVER_TEMP_TABLE_CONTEXT_KEYWORDS = new Set(["table", "from", "join", "into", "update", "truncate"]); export function extractSqlParameters(sql: string): string[] { return extractSqlParameterDescriptors(sql).map((descriptor) => descriptor.key); @@ -69,7 +70,7 @@ export function sqlParameterLiteral(input: SqlParameterInput): string { function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] { const occurrences: ParameterOccurrence[] = []; - const declaredSqlServerVariables = collectDeclaredSqlServerVariables(sql); + const nativeSqlServerParameters = collectNativeSqlServerParameters(sql); let i = 0; let dollarQuoteEnd = ""; let positionalIndex = 0; @@ -146,13 +147,13 @@ function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] { } } } - if (ch === "#") { + if (isHashLineComment(sql, i)) { i = skipLine(sql, i + 1); continue; } if (ch === "@") { const name = readParameterName(sql, i + 1); - if (name && next !== "@" && sql[i - 1] !== "@" && !declaredSqlServerVariables.has(name.toLowerCase())) { + if (name && next !== "@" && sql[i - 1] !== "@" && !nativeSqlServerParameters.declared.has(name.toLowerCase()) && !nativeSqlServerParameters.ignoredStarts.has(i)) { occurrences.push({ key: name, name, @@ -179,8 +180,9 @@ function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] { return occurrences; } -function collectDeclaredSqlServerVariables(sql: string): Set { +function collectNativeSqlServerParameters(sql: string): { declared: Set; ignoredStarts: Set } { const declared = new Set(); + const ignoredStarts = new Set(); let i = 0; let dollarQuoteEnd = ""; @@ -219,7 +221,7 @@ function collectDeclaredSqlServerVariables(sql: string): Set { continue; } } - if (ch === "#") { + if (isHashLineComment(sql, i)) { i = skipLine(sql, i + 1); continue; } @@ -227,10 +229,26 @@ function collectDeclaredSqlServerVariables(sql: string): Set { i = collectDeclareStatementVariables(sql, i + "declare".length, declared); continue; } + if (matchesWord(sql, i, "set")) { + i = collectSetStatementVariables(sql, i + "set".length, declared); + continue; + } + if (matchesWord(sql, i, "select")) { + i = collectSelectAssignmentVariables(sql, i + "select".length, declared); + continue; + } + if ((matchesWord(sql, i, "create") || matchesWord(sql, i, "alter")) && isRoutineDefinitionStart(sql, i)) { + i = collectRoutineDefinitionVariables(sql, i, declared); + continue; + } + if (matchesWord(sql, i, "exec") || matchesWord(sql, i, "execute")) { + i = collectExecNamedArgumentStarts(sql, i + (matchesWord(sql, i, "exec") ? "exec".length : "execute".length), ignoredStarts); + continue; + } i += 1; } - return declared; + return { declared, ignoredStarts }; } function collectDeclareStatementVariables(sql: string, start: number, declared: Set): number { @@ -256,7 +274,7 @@ function collectDeclareStatementVariables(sql: string, start: number, declared: i = skipBlockComment(sql, i + 2); continue; } - if (ch === "#") { + if (isHashLineComment(sql, i)) { i = skipLine(sql, i + 1); continue; } @@ -273,6 +291,208 @@ function collectDeclareStatementVariables(sql: string, start: number, declared: return i; } +function collectSetStatementVariables(sql: string, start: number, declared: Set): number { + let i = start; + while (i < sql.length) { + const ch = sql[i]; + const next = sql[i + 1]; + if (ch === ";") return i + 1; + if (isLineStatementStart(sql, i) && isSqlStatementKeyword(sql, i)) return i; + 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 name = readParameterName(sql, i + 1); + if (name && next !== "@" && sql[i - 1] !== "@" && isSetAssignmentTarget(sql, i + 1 + name.length)) { + declared.add(name.toLowerCase()); + i += 1 + name.length; + continue; + } + } + i += 1; + } + return i; +} + +function collectSelectAssignmentVariables(sql: string, start: number, declared: Set): number { + let i = start; + while (i < sql.length) { + const ch = sql[i]; + const next = sql[i + 1]; + if (ch === ";") return i + 1; + if (isLineStatementStart(sql, i) && isSqlStatementKeyword(sql, i)) return i; + if (matchesWord(sql, i, "from")) return i; + 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 name = readParameterName(sql, i + 1); + if (name && next !== "@" && sql[i - 1] !== "@" && isSetAssignmentTarget(sql, i + 1 + name.length)) { + declared.add(name.toLowerCase()); + i += 1 + name.length; + continue; + } + } + i += 1; + } + return i; +} + +function collectRoutineDefinitionVariables(sql: string, start: number, declared: Set): number { + let i = start; + while (i < sql.length) { + const ch = sql[i]; + const next = sql[i + 1]; + if (ch === ";") return i + 1; + if (matchesWord(sql, i, "as") || matchesWord(sql, i, "returns")) return i; + 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 name = readParameterName(sql, i + 1); + if (name && next !== "@" && sql[i - 1] !== "@") { + declared.add(name.toLowerCase()); + i += 1 + name.length; + continue; + } + } + i += 1; + } + return i; +} + +function collectExecNamedArgumentStarts(sql: string, start: number, ignoredStarts: Set): number { + let i = start; + while (i < sql.length) { + const ch = sql[i]; + const next = sql[i + 1]; + if (ch === ";") return i + 1; + if (isLineStatementStart(sql, i) && isSqlStatementKeyword(sql, i)) return i; + 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 name = readParameterName(sql, i + 1); + if (name && next !== "@" && sql[i - 1] !== "@" && isSetAssignmentTarget(sql, i + 1 + name.length)) { + ignoredStarts.add(i); + i += 1 + name.length; + continue; + } + } + i += 1; + } + return i; +} + +function isRoutineDefinitionStart(sql: string, start: number): boolean { + const keyword = matchesWord(sql, start, "create") ? "create" : matchesWord(sql, start, "alter") ? "alter" : ""; + if (!keyword) return false; + + let next = readNextKeyword(sql, start + keyword.length); + if (!next) return false; + if (keyword === "create" && next.word === "or") { + const afterOr = readNextKeyword(sql, next.end); + if (!afterOr || (afterOr.word !== "alter" && afterOr.word !== "replace")) return false; + next = readNextKeyword(sql, afterOr.end); + if (!next) return false; + } + return next.word === "procedure" || next.word === "proc" || next.word === "function"; +} + +function readNextKeyword(sql: string, start: number): { word: string; end: number } | null { + 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; + } + break; + } + if (!PARAMETER_NAME_START_RE.test(sql[i] ?? "")) return null; + let end = i + 1; + while (end < sql.length && PARAMETER_NAME_CHAR_RE.test(sql[end])) end += 1; + return { word: sql.slice(i, end).toLowerCase(), end }; +} + +function isSetAssignmentTarget(sql: string, start: number): boolean { + let i = start; + while (i < sql.length && /\s/.test(sql[i])) i += 1; + return sql[i] === "=" || (sql[i] === ":" && sql[i + 1] === "="); +} + function isLineStatementStart(sql: string, start: number): boolean { let i = start - 1; while (i >= 0 && (sql[i] === " " || sql[i] === "\t" || sql[i] === "\r")) i -= 1; @@ -340,6 +560,31 @@ function skipBlockComment(sql: string, start: number): number { return end === -1 ? sql.length : end + 2; } +function isHashLineComment(sql: string, start: number): boolean { + if (sql[start] !== "#" || sql[start + 1] === "{") return false; + // Keep SQL Server #temp table names parseable while treating other # tokens as MySQL-style comments. + return !isSqlServerTempTableReference(sql, start); +} + +function isSqlServerTempTableReference(sql: string, start: number): boolean { + let nameStart = start + 1; + if (sql[nameStart] === "#") nameStart += 1; + if (!PARAMETER_NAME_START_RE.test(sql[nameStart] ?? "")) return false; + + const previous = previousKeyword(sql, start); + return !!previous && SQL_SERVER_TEMP_TABLE_CONTEXT_KEYWORDS.has(previous); +} + +function previousKeyword(sql: string, start: number): string { + let end = start - 1; + while (end >= 0 && /\s/.test(sql[end])) end -= 1; + let begin = end; + while (begin >= 0 && PARAMETER_NAME_CHAR_RE.test(sql[begin])) begin -= 1; + begin += 1; + if (begin > end || !PARAMETER_NAME_START_RE.test(sql[begin] ?? "")) return ""; + return sql.slice(begin, end + 1).toLowerCase(); +} + function readDollarQuoteMarker(sql: string, start: number): string { const match = sql.slice(start).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/); return match?.[0] ?? "";