feat(sql): add support for @set variables in scripts and copy button

This commit is contained in:
miracle 2026-07-09 00:34:51 +08:00 committed by GitHub
parent 3bc208dbf9
commit 135d4e15db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 414 additions and 5 deletions

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { Braces } from "@lucide/vue";
import { Braces, Copy } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -11,10 +11,13 @@ import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import { loadSqlParameterHistory, rememberSqlParameterValues } from "@/lib/sql/sqlParameterHistory";
import { substituteSqlParameters, type SqlParameterDescriptor, type SqlParameterInput, type SqlParameterSyntax, type SqlParameterValueKind } from "@/lib/sql/sqlParameters";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import type { DatabaseType } from "@/types/database";
const { t } = useI18n();
const { highlight } = useSqlHighlighter();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
@ -105,6 +108,15 @@ function execute() {
open.value = false;
emit("execute", resolvedSql.value);
}
async function copyResolvedSql() {
try {
await copyToClipboard(resolvedSql.value);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
</script>
<template>
@ -183,6 +195,10 @@ function execute() {
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="outline" @click="copyResolvedSql">
<Copy class="mr-1.5 h-4 w-4" />
{{ t("grid.copy") }}
</Button>
<Button @click="execute">{{ t("sqlParameters.execute") }}</Button>
</DialogFooter>
</DialogContent>

View File

@ -12,6 +12,7 @@ import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
import { classifyRedisCommandSafety, firstRedisCommandToken } from "@/lib/redis/redisCommandSafety";
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { extractSqlParameterDescriptors, type SqlParameterDescriptor } from "@/lib/sql/sqlParameters";
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@ -64,10 +65,10 @@ export function useSqlExecution(deps: {
const sqlParameterDatabaseType = ref<DatabaseType | undefined>();
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<string> {
if (typeof source === "string") return source;
if (deps.resolveExecutableSql) return await deps.resolveExecutableSql(source);
if (isSqlExecutionSnapshot(source)) return resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos });
return deps.executableSql.value;
if (typeof source === "string") return expandSqlVariables(source).sql;
if (deps.resolveExecutableSql) return expandSqlVariables(await deps.resolveExecutableSql(source)).sql;
if (isSqlExecutionSnapshot(source)) return expandSqlVariables(resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos })).sql;
return expandSqlVariables(deps.executableSql.value).sql;
}
async function tryExecute(sqlOverride?: SqlExecutionOverride) {

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
describe("expandSqlVariables", () => {
it("returns the SQL unchanged when there is no @set declaration", () => {
const sql = "select * from t where id = @id";
expect(expandSqlVariables(sql)).toEqual({ sql, expanded: false });
});
it("inlines a declared IN-list verbatim across the script", () => {
const sql = ["@set client_id = (606,322,634);", "select * from invoices where client_id in @client_id"].join("\n");
const { sql: result, expanded } = expandSqlVariables(sql);
expect(expanded).toBe(true);
expect(result).toBe("select * from invoices where client_id in (606,322,634)");
});
it("inlines a quoted string value verbatim", () => {
const sql = ["@set date_start = '2026-07-04 00:00:00';", "select * from t where created_at < @date_start"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select * from t where created_at < '2026-07-04 00:00:00'");
});
it("expands the same variable in multiple places", () => {
const sql = ["@set tenant = 42;", "select @tenant, count(*) from t where tenant_id = @tenant"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select 42, count(*) from t where tenant_id = 42");
});
it("supports several declarations", () => {
const sql = ["@set a = 1;", "@set b = 'x';", "select @a, @b"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select 1, 'x'");
});
it("leaves undeclared @name references untouched", () => {
const sql = ["@set a = 1;", "select @a, @b, @@version"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select 1, @b, @@version");
});
it("does not expand references inside strings, comments, or quoted identifiers", () => {
const sql = ["@set a = 1;", "select '@a' as s, \"@a\" as q, `@a` as b -- @a"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select '@a' as s, \"@a\" as q, `@a` as b -- @a");
});
it("does not treat @set inside a string as a declaration", () => {
const sql = "select '@set a = 1;' as note, @a";
expect(expandSqlVariables(sql)).toEqual({ sql, expanded: false });
});
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)");
});
it("matches @set case-insensitively", () => {
const sql = ["@SET a = 7;", "select @a"].join("\n");
expect(expandSqlVariables(sql).sql).toBe("select 7");
});
it("does not treat @settings as an @set declaration", () => {
const sql = "select @settings from t";
expect(expandSqlVariables(sql)).toEqual({ sql, expanded: false });
});
});

View File

@ -0,0 +1,331 @@
// Client-side SQL variable expansion.
//
// Lets users declare reusable values inside a SQL script with `@set name = value;`
// and reference them elsewhere as `@name`. Expansion happens entirely on the client:
// declarations are stripped and every reference is replaced with the declared value
// verbatim (raw). Because the SQL sent to the server contains only plain literals,
// this works uniformly across PostgreSQL, MySQL, SQL Server and every other backend
// regardless of whether they have native variable support.
//
// This is intentionally separate from the placeholder parameter system
// (`sqlParameters.ts`). A `@name` reference is only expanded when a matching
// `@set` declaration exists in the same script; any other `@name` (SQL Server
// native variables, `@@version`, dialog parameters) is left untouched.
const VARIABLE_NAME_START_RE = /[\p{L}_]/u;
const VARIABLE_NAME_CHAR_RE = /[\p{L}\p{N}_]/u;
export interface SqlVariableExpansion {
sql: string;
expanded: boolean;
}
interface DeclarationSpan {
name: string;
value: string;
start: number;
end: number;
}
/**
* Expand `@set name = value;` declarations within a SQL script.
*
* Returns the SQL with declarations removed and `@name` references replaced by
* their declared values. When no declaration is found the input is returned
* unchanged with `expanded: false`.
*/
export function expandSqlVariables(sql: string): SqlVariableExpansion {
const declarations = collectDeclarations(sql);
if (!declarations.length) return { sql, expanded: false };
const values = new Map<string, string>();
for (const declaration of declarations) {
values.set(declaration.name.toLowerCase(), declaration.value);
}
// Remove declaration spans first (right-to-left keeps earlier offsets valid),
// then replace references in the remaining text.
let result = sql;
for (let i = declarations.length - 1; i >= 0; i -= 1) {
const declaration = declarations[i];
result = stripDeclaration(result, declaration.start, declaration.end);
}
result = replaceReferences(result, values);
return { sql: result, expanded: true };
}
function collectDeclarations(sql: string): DeclarationSpan[] {
const declarations: DeclarationSpan[] = [];
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 (ch === "$") {
const marker = readDollarQuoteMarker(sql, i);
if (marker) {
dollarQuoteEnd = marker;
i += marker.length;
continue;
}
}
if (ch === "@" && matchesWord(sql, i + 1, "set") && isStatementStart(sql, i)) {
const declaration = readDeclaration(sql, i);
if (declaration) {
declarations.push(declaration);
i = declaration.end;
continue;
}
}
i += 1;
}
return declarations;
}
// 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 {
let i = start + 1 + "set".length;
i = skipInlineWhitespace(sql, i);
const name = readVariableName(sql, i);
if (!name) return null;
i += name.length;
i = skipInlineWhitespace(sql, i);
if (sql[i] !== "=") return null;
i += 1;
i = skipInlineWhitespace(sql, i);
const valueStart = i;
const valueEnd = readValueEnd(sql, i);
const value = sql.slice(valueStart, valueEnd).trim();
if (!value) return null;
// Consume the terminating semicolon so the declaration disappears cleanly.
let end = valueEnd;
if (sql[end] === ";") end += 1;
return { name, value, start, end };
}
function readValueEnd(sql: string, start: number): number {
let i = start;
let depth = 0;
while (i < sql.length) {
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 === "-") return i;
if (ch === "/" && next === "*") return i;
if (ch === "$") {
const marker = readDollarQuoteMarker(sql, i);
if (marker) {
const end = sql.indexOf(marker, i + marker.length);
i = end === -1 ? sql.length : end + marker.length;
continue;
}
}
if (ch === "(") depth += 1;
else if (ch === ")") depth = Math.max(0, depth - 1);
else if ((ch === ";" || ch === "\n") && depth === 0) return i;
i += 1;
}
return sql.length;
}
function replaceReferences(sql: string, values: Map<string, string>): string {
let result = "";
let i = 0;
let dollarQuoteEnd = "";
while (i < sql.length) {
if (dollarQuoteEnd) {
const end = sql.indexOf(dollarQuoteEnd, i);
const stop = end === -1 ? sql.length : end + dollarQuoteEnd.length;
result += sql.slice(i, stop);
i = stop;
dollarQuoteEnd = "";
continue;
}
const ch = sql[i];
const next = sql[i + 1];
if (ch === "'" || ch === '"' || ch === "`") {
const end = skipQuoted(sql, i, ch);
result += sql.slice(i, end);
i = end;
continue;
}
if (ch === "[") {
const end = skipBracketIdentifier(sql, i);
result += sql.slice(i, end);
i = end;
continue;
}
if (ch === "-" && next === "-") {
const end = skipLine(sql, i + 2);
result += sql.slice(i, end);
i = end;
continue;
}
if (ch === "/" && next === "*") {
const end = skipBlockComment(sql, i + 2);
result += sql.slice(i, end);
i = end;
continue;
}
if (ch === "$") {
const marker = readDollarQuoteMarker(sql, i);
if (marker) {
result += marker;
i += marker.length;
dollarQuoteEnd = marker;
continue;
}
}
if (ch === "@" && next !== "@" && sql[i - 1] !== "@") {
const name = readVariableName(sql, i + 1);
if (name) {
const value = values.get(name.toLowerCase());
if (value !== undefined) {
result += value;
i += 1 + name.length;
continue;
}
}
}
result += ch;
i += 1;
}
return result;
}
// Remove a declaration span, collapsing the leftover blank line it leaves behind
// so the expanded SQL stays tidy.
function stripDeclaration(sql: string, start: number, end: number): string {
let lineStart = start;
while (lineStart > 0 && sql[lineStart - 1] !== "\n") {
if (sql[lineStart - 1] !== " " && sql[lineStart - 1] !== "\t" && sql[lineStart - 1] !== "\r") break;
lineStart -= 1;
}
const trimmableStart = lineStart === 0 || sql[lineStart - 1] === "\n" ? lineStart : start;
let lineEnd = end;
while (lineEnd < sql.length && (sql[lineEnd] === " " || sql[lineEnd] === "\t" || sql[lineEnd] === "\r")) lineEnd += 1;
if (trimmableStart === lineStart && sql[lineEnd] === "\n") lineEnd += 1;
return sql.slice(0, trimmableStart) + sql.slice(lineEnd);
}
function isStatementStart(sql: string, start: number): boolean {
let i = start - 1;
while (i >= 0 && /\s/.test(sql[i])) i -= 1;
return i < 0 || sql[i] === ";";
}
function matchesWord(sql: string, start: number, word: string): boolean {
const value = sql.slice(start, start + word.length);
if (value.toLowerCase() !== word) return false;
return !VARIABLE_NAME_CHAR_RE.test(sql[start + word.length] ?? "");
}
function readVariableName(sql: string, start: number): string {
if (!VARIABLE_NAME_START_RE.test(sql[start] ?? "")) return "";
let i = start + 1;
while (i < sql.length && VARIABLE_NAME_CHAR_RE.test(sql[i])) i += 1;
return sql.slice(start, i);
}
function skipInlineWhitespace(sql: string, start: number): number {
let i = start;
while (i < sql.length && (sql[i] === " " || sql[i] === "\t" || sql[i] === "\r")) i += 1;
return i;
}
function skipQuoted(sql: string, start: number, quote: string): number {
let i = start + 1;
while (i < sql.length) {
if (sql[i] === "\\" && quote === "'" && i + 1 < sql.length) {
i += 2;
continue;
}
if (sql[i] === quote) {
if (sql[i + 1] === quote) {
i += 2;
continue;
}
return i + 1;
}
i += 1;
}
return sql.length;
}
function skipBracketIdentifier(sql: string, start: number): number {
let i = start + 1;
while (i < sql.length) {
if (sql[i] === "]") {
if (sql[i + 1] === "]") {
i += 2;
continue;
}
return i + 1;
}
i += 1;
}
return sql.length;
}
function skipLine(sql: string, start: number): number {
const nextNewline = sql.indexOf("\n", start);
return nextNewline === -1 ? sql.length : nextNewline + 1;
}
function skipBlockComment(sql: string, start: number): number {
const end = sql.indexOf("*/", start);
return end === -1 ? sql.length : end + 2;
}
function readDollarQuoteMarker(sql: string, start: number): string {
const match = sql.slice(start).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
return match?.[0] ?? "";
}