fix(sql): ignore native SQL variables

This commit is contained in:
miracle 2026-07-07 22:24:48 +08:00 committed by GitHub
parent 6cba08fccb
commit 9288717ac4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 440 additions and 9 deletions

View File

@ -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<string, string>();
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 | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(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");
});
});

View File

@ -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", () => {

View File

@ -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<string> {
function collectNativeSqlServerParameters(sql: string): { declared: Set<string>; ignoredStarts: Set<number> } {
const declared = new Set<string>();
const ignoredStarts = new Set<number>();
let i = 0;
let dollarQuoteEnd = "";
@ -219,7 +221,7 @@ function collectDeclaredSqlServerVariables(sql: string): Set<string> {
continue;
}
}
if (ch === "#") {
if (isHashLineComment(sql, i)) {
i = skipLine(sql, i + 1);
continue;
}
@ -227,10 +229,26 @@ function collectDeclaredSqlServerVariables(sql: string): Set<string> {
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<string>): 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<string>): 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<string>): 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<string>): 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>): 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] ?? "";