[Feat]:实现SQL变量替换 (#2597)

This commit is contained in:
miracle 2026-07-06 13:21:45 +08:00 committed by GitHub
parent 987b0fd263
commit b118ca31ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 306 additions and 33 deletions

View File

@ -9,7 +9,7 @@ import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import { loadSqlParameterHistory, rememberSqlParameterValues } from "@/lib/sql/sqlParameterHistory";
import { substituteSqlParameters, type SqlParameterInput, type SqlParameterValueKind } from "@/lib/sql/sqlParameters";
import { substituteSqlParameters, type SqlParameterDescriptor, type SqlParameterInput, type SqlParameterSyntax, type SqlParameterValueKind } from "@/lib/sql/sqlParameters";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
const { t } = useI18n();
@ -19,7 +19,7 @@ const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
sql: string;
parameters: string[];
parameters: SqlParameterDescriptor[];
}>();
const emit = defineEmits<{
@ -33,6 +33,14 @@ let closeHistoryTimer: ReturnType<typeof setTimeout> | undefined;
const parameterKinds: SqlParameterValueKind[] = ["string", "number", "boolean", "null", "raw"];
const syntaxLabels: Record<SqlParameterSyntax, string> = {
positional: "?",
named: ":name",
shell: "${name}",
mybatis: "#{name}",
sqlserver: "@name",
};
const resolvedSql = computed(() => substituteSqlParameters(props.sql, values.value));
const highlightedSql = computed(() => highlight(resolvedSql.value));
@ -42,10 +50,10 @@ watch(
if (!isOpen) return;
const next: Record<string, SqlParameterInput> = {};
const nextHistories: Record<string, SqlParameterInput[]> = {};
for (const name of props.parameters) {
const history = loadSqlParameterHistory(name);
nextHistories[name] = history;
next[name] = values.value[name] ?? history[0] ?? { kind: "string", value: "" };
for (const parameter of props.parameters) {
const history = loadSqlParameterHistory(parameter.key);
nextHistories[parameter.key] = history;
next[parameter.key] = values.value[parameter.key] ?? history[0] ?? { kind: "string", value: "" };
}
values.value = next;
histories.value = nextHistories;
@ -111,15 +119,17 @@ function execute() {
<p class="text-sm text-muted-foreground">{{ t("sqlParameters.description") }}</p>
<div class="relative z-20 max-h-[302px] overflow-auto rounded-md border bg-background">
<div class="min-w-[580px]">
<div class="sticky top-0 z-10 grid grid-cols-[minmax(140px,1fr)_132px_minmax(180px,1.5fr)] border-b bg-muted px-3 py-2 text-xs font-medium text-muted-foreground">
<div class="min-w-[680px]">
<div class="sticky top-0 z-10 grid grid-cols-[minmax(140px,1fr)_104px_132px_minmax(180px,1.5fr)] border-b bg-muted px-3 py-2 text-xs font-medium text-muted-foreground">
<div>{{ t("sqlParameters.name") }}</div>
<div>{{ t("sqlParameters.syntax") }}</div>
<div>{{ t("sqlParameters.type") }}</div>
<div>{{ t("sqlParameters.value") }}</div>
</div>
<div v-for="name in parameters" :key="name" class="grid grid-cols-[minmax(140px,1fr)_132px_minmax(180px,1.5fr)] items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0">
<div class="min-w-0 truncate font-mono text-xs">{{ name }}</div>
<Select :model-value="values[name]?.kind || 'string'" @update:model-value="(value) => updateKind(name, value as SqlParameterValueKind)">
<div v-for="parameter in parameters" :key="parameter.key" class="grid grid-cols-[minmax(140px,1fr)_104px_132px_minmax(180px,1.5fr)] items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0">
<div class="min-w-0 truncate font-mono text-xs">{{ parameter.name }}</div>
<div class="min-w-0 truncate font-mono text-[11px] text-muted-foreground">{{ syntaxLabels[parameter.syntax] }}</div>
<Select :model-value="values[parameter.key]?.kind || 'string'" @update:model-value="(value) => updateKind(parameter.key, value as SqlParameterValueKind)">
<SelectTrigger class="h-8 bg-background text-xs">
<SelectValue />
</SelectTrigger>
@ -130,28 +140,28 @@ function execute() {
</SelectContent>
</Select>
<div class="relative min-w-0">
<Popover :open="activeHistoryName === name && filteredSqlParameterHistory(name).length > 0">
<Popover :open="activeHistoryName === parameter.key && filteredSqlParameterHistory(parameter.key).length > 0">
<PopoverAnchor as-child>
<Input
:model-value="values[name]?.value || ''"
:model-value="values[parameter.key]?.value || ''"
class="h-8 bg-background font-mono text-xs"
:disabled="values[name]?.kind === 'null'"
:disabled="values[parameter.key]?.kind === 'null'"
autocomplete="off"
data-lpignore="true"
data-form-type="other"
:placeholder="t('sqlParameters.valuePlaceholder')"
@focus="focusParameterInput(name, $event)"
@blur="closeParameterHistory(name)"
@update:model-value="(value) => updateValue(name, String(value))"
@focus="focusParameterInput(parameter.key, $event)"
@blur="closeParameterHistory(parameter.key)"
@update:model-value="(value) => updateValue(parameter.key, String(value))"
/>
</PopoverAnchor>
<PopoverContent align="start" side="bottom" class="z-[80] w-[var(--reka-popover-trigger-width)] max-h-40 gap-0 overflow-auto p-1" @open-auto-focus.prevent>
<button
v-for="entry in filteredSqlParameterHistory(name)"
v-for="entry in filteredSqlParameterHistory(parameter.key)"
:key="`${entry.kind}:${entry.value}`"
type="button"
class="flex w-full min-w-0 items-center justify-between gap-2 rounded px-2 py-1 text-left text-xs hover:bg-accent hover:text-accent-foreground"
@mousedown.prevent="selectHistoryEntry(name, entry)"
@mousedown.prevent="selectHistoryEntry(parameter.key, entry)"
>
<TruncatedTextTooltip :text="entry.value" class="min-w-0 flex-1 font-mono" side="top" :delay="150" />
<span class="shrink-0 text-[10px] uppercase text-muted-foreground">{{ t(`sqlParameters.kind.${entry.kind}`) }}</span>

View File

@ -20,6 +20,7 @@ const DataGenerateDialog = defineAsyncComponent(() => import("@/components/gener
import { useConnectionStore } from "@/stores/connectionStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import type { SqlParameterDescriptor } from "@/lib/sql/sqlParameters";
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
const props = defineProps<{
@ -31,7 +32,7 @@ const props = defineProps<{
suppressDangerConfirm: boolean;
showSqlParameterDialog: boolean;
sqlParameterSourceSql: string;
sqlParameterNames: string[];
sqlParameterNames: SqlParameterDescriptor[];
}>();
const emit = defineEmits<{

View File

@ -11,7 +11,7 @@ import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
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 { extractSqlParameters } from "@/lib/sql/sqlParameters";
import { extractSqlParameterDescriptors, type SqlParameterDescriptor } from "@/lib/sql/sqlParameters";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@ -60,7 +60,7 @@ export function useSqlExecution(deps: {
const explainMode = ref<"explain" | "autotrace">("explain");
const showSqlParameterDialog = ref(false);
const sqlParameterSourceSql = ref("");
const sqlParameterNames = ref<string[]>([]);
const sqlParameterNames = ref<SqlParameterDescriptor[]>([]);
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<string> {
if (typeof source === "string") return source;
@ -107,7 +107,7 @@ export function useSqlExecution(deps: {
}
function prepareSqlParameterDialog(sql: string): boolean {
const parameters = extractSqlParameters(sql);
const parameters = extractSqlParameterDescriptors(sql);
if (!parameters.length) return false;
sqlParameterSourceSql.value = sql;
sqlParameterNames.value = parameters;

View File

@ -2124,6 +2124,7 @@ export default {
title: "SQL Parameters",
description: "Fill values for SQL template placeholders. DBX replaces them before executing the SQL.",
name: "Parameter",
syntax: "Syntax",
type: "Type",
value: "Value",
valuePlaceholder: "Enter value",

View File

@ -2068,6 +2068,7 @@ export default withEnglishFallback({
title: "Parámetros SQL",
description: "Completa valores para marcadores de plantilla SQL. DBX los reemplaza antes de ejecutar el SQL.",
name: "Parámetro",
syntax: "Sintaxis",
type: "Tipo",
value: "Valor",
valuePlaceholder: "Introduce un valor",

View File

@ -2066,6 +2066,7 @@ export default withEnglishFallback({
title: "Parametri SQL",
description: "Compila i valori per i segnaposto del template SQL. DBX li sostituisce prima di eseguire l'SQL.",
name: "Parametro",
syntax: "Sintassi",
type: "Tipo",
value: "Valore",
valuePlaceholder: "Inserisci valore",

View File

@ -2066,6 +2066,7 @@ export default withEnglishFallback({
title: "SQLパラメーター",
description: "SQLテンプレートのプレースホルダーに値を入力します。DBXは実行前にSQLリテラルへ置換します。",
name: "パラメーター",
syntax: "Syntax",
type: "型",
value: "値",
valuePlaceholder: "値を入力",

View File

@ -2067,6 +2067,7 @@ export default withEnglishFallback({
title: "Parâmetros SQL",
description: "Preencha valores para marcadores de template SQL. O DBX substitui antes de executar o SQL.",
name: "Parâmetro",
syntax: "Sintaxe",
type: "Tipo",
value: "Valor",
valuePlaceholder: "Digite o valor",

View File

@ -2124,6 +2124,7 @@ export default withEnglishFallback({
title: "SQL 参数",
description: "为 SQL 模板占位符填写参数值DBX 会在执行前替换为 SQL 字面量。",
name: "参数名",
syntax: "Syntax",
type: "类型",
value: "参数值",
valuePlaceholder: "输入参数值",

View File

@ -1970,6 +1970,7 @@ export default withEnglishFallback({
title: "SQL 參數",
description: "為 SQL 模板佔位符填寫參數值DBX 會在執行前替換為 SQL 字面量。",
name: "參數名",
syntax: "Syntax",
type: "類型",
value: "參數值",
valuePlaceholder: "輸入參數值",

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { extractSqlParameters, sqlParameterLiteral, substituteSqlParameters } from "@/lib/sql/sqlParameters";
import { extractSqlParameterDescriptors, extractSqlParameters, sqlParameterLiteral, substituteSqlParameters } from "@/lib/sql/sqlParameters";
describe("extractSqlParameters", () => {
it("extracts unique template parameters in order", () => {
@ -23,6 +23,43 @@ describe("extractSqlParameters", () => {
const sql = "select $$ ${body_param} $$, $tag$ ${tag_param} $tag$, ${real_param}";
expect(extractSqlParameters(sql)).toEqual(["real_param"]);
});
it("extracts supported placeholder syntaxes in order", () => {
const sql = "select ? as a, :named as b, ${shell_name} as c, #{mybatis_name} as d, @sql_server_name as e";
expect(extractSqlParameters(sql)).toEqual(["?1", "named", "shell_name", "mybatis_name", "sql_server_name"]);
});
it("describes each placeholder syntax for the parameter dialog", () => {
const sql = "select ? as a, :named as b, ${shell_name} as c, #{mybatis_name} as d, @sql_server_name as e";
expect(extractSqlParameterDescriptors(sql)).toEqual([
{ key: "?1", name: "?1", syntax: "positional", token: "?" },
{ key: "named", name: "named", syntax: "named", token: ":named" },
{ key: "shell_name", name: "shell_name", syntax: "shell", token: "${shell_name}" },
{ key: "mybatis_name", name: "mybatis_name", syntax: "mybatis", token: "#{mybatis_name}" },
{ key: "sql_server_name", name: "sql_server_name", syntax: "sqlserver", token: "@sql_server_name" },
]);
});
it("ignores declared SQL Server variables and system variables", () => {
const sql = `
declare @id int = 1, @name nvarchar(50);
select @@version, @id, @name, @input_value
`;
expect(extractSqlParameters(sql)).toEqual(["input_value"]);
});
it("stops SQL Server declaration scanning when a new statement starts without a semicolon", () => {
const sql = `
declare @id int = 1
select @id, @tenant_id
`;
expect(extractSqlParameters(sql)).toEqual(["tenant_id"]);
});
it("does not treat PostgreSQL casts or assignment operators as named parameters", () => {
const sql = "select value::int, value := 1, :actual_value";
expect(extractSqlParameters(sql)).toEqual(["actual_value"]);
});
});
describe("substituteSqlParameters", () => {
@ -47,6 +84,35 @@ describe("substituteSqlParameters", () => {
}),
).toBe("select 'O''Reilly', NULL, current_date");
});
it("replaces all supported placeholder syntaxes with SQL literals", () => {
const sql = "select ? as a, :named as b, ${shell_name} as c, #{mybatis_name} as d, @sql_server_name as e";
expect(
substituteSqlParameters(sql, {
"?1": { kind: "number", value: "42" },
named: { kind: "string", value: "alpha" },
shell_name: { kind: "boolean", value: "yes" },
mybatis_name: { kind: "null", value: "" },
sql_server_name: { kind: "raw", value: "current_timestamp" },
}),
).toBe("select 42 as a, 'alpha' as b, TRUE as c, NULL as d, current_timestamp as e");
});
it("replaces repeated named placeholders once and positional placeholders independently", () => {
const sql = "select :name, :name, ?, ?";
expect(
substituteSqlParameters(sql, {
name: { kind: "string", value: "same" },
"?1": { kind: "number", value: "1" },
"?2": { kind: "number", value: "2" },
}),
).toBe("select 'same', 'same', 1, 2");
});
it("leaves declared SQL Server variables untouched while replacing undeclared variables", () => {
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");
});
});
describe("sqlParameterLiteral", () => {

View File

@ -5,20 +5,42 @@ export interface SqlParameterInput {
value: string;
}
interface ParameterOccurrence {
export type SqlParameterSyntax = "positional" | "named" | "shell" | "mybatis" | "sqlserver";
export interface SqlParameterDescriptor {
key: string;
name: string;
syntax: SqlParameterSyntax;
token: string;
}
interface ParameterOccurrence extends SqlParameterDescriptor {
start: number;
end: number;
}
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;
export function extractSqlParameters(sql: string): string[] {
return extractSqlParameterDescriptors(sql).map((descriptor) => descriptor.key);
}
export function extractSqlParameterDescriptors(sql: string): SqlParameterDescriptor[] {
const names = new Set<string>();
const descriptors: SqlParameterDescriptor[] = [];
for (const occurrence of findSqlParameterOccurrences(sql)) {
names.add(occurrence.name);
if (names.has(occurrence.key)) continue;
names.add(occurrence.key);
descriptors.push({
key: occurrence.key,
name: occurrence.name,
syntax: occurrence.syntax,
token: occurrence.token,
});
}
return [...names];
return descriptors;
}
export function substituteSqlParameters(sql: string, values: Record<string, SqlParameterInput>): string {
@ -29,7 +51,7 @@ export function substituteSqlParameters(sql: string, values: Record<string, SqlP
let cursor = 0;
for (const occurrence of occurrences) {
result += sql.slice(cursor, occurrence.start);
result += sqlParameterLiteral(values[occurrence.name] ?? { kind: "string", value: "" });
result += sqlParameterLiteral(values[occurrence.key] ?? { kind: "string", value: "" });
cursor = occurrence.end;
}
result += sql.slice(cursor);
@ -47,8 +69,10 @@ export function sqlParameterLiteral(input: SqlParameterInput): string {
function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] {
const occurrences: ParameterOccurrence[] = [];
const declaredSqlServerVariables = collectDeclaredSqlServerVariables(sql);
let i = 0;
let dollarQuoteEnd = "";
let positionalIndex = 0;
while (i < sql.length) {
if (dollarQuoteEnd) {
@ -74,25 +98,73 @@ function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] {
i = skipLine(sql, i + 2);
continue;
}
if (ch === "#") {
i = skipLine(sql, i + 1);
continue;
}
if (ch === "/" && next === "*") {
i = skipBlockComment(sql, i + 2);
continue;
}
if (ch === "?") {
positionalIndex += 1;
const key = `?${positionalIndex}`;
occurrences.push({ key, name: key, syntax: "positional", token: "?", start: i, end: i + 1 });
i += 1;
continue;
}
if (ch === ":") {
const name = readParameterName(sql, i + 1);
if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=") {
occurrences.push({
key: name,
name,
syntax: "named",
token: sql.slice(i, i + 1 + name.length),
start: i,
end: i + 1 + name.length,
});
i += 1 + name.length;
continue;
}
}
if (ch === "$" && next === "{") {
const end = sql.indexOf("}", i + 2);
if (end !== -1) {
const name = sql.slice(i + 2, end).trim();
if (PARAMETER_NAME_RE.test(name)) {
occurrences.push({ name, start: i, end: end + 1 });
occurrences.push({ key: name, name, syntax: "shell", token: sql.slice(i, end + 1), start: i, end: end + 1 });
i = end + 1;
continue;
}
}
}
if (ch === "#" && next === "{") {
const end = sql.indexOf("}", i + 2);
if (end !== -1) {
const name = sql.slice(i + 2, end).trim();
if (PARAMETER_NAME_RE.test(name)) {
occurrences.push({ key: name, name, syntax: "mybatis", token: sql.slice(i, end + 1), start: i, end: end + 1 });
i = end + 1;
continue;
}
}
}
if (ch === "#") {
i = skipLine(sql, i + 1);
continue;
}
if (ch === "@") {
const name = readParameterName(sql, i + 1);
if (name && next !== "@" && sql[i - 1] !== "@" && !declaredSqlServerVariables.has(name.toLowerCase())) {
occurrences.push({
key: name,
name,
syntax: "sqlserver",
token: sql.slice(i, i + 1 + name.length),
start: i,
end: i + 1 + name.length,
});
i += 1 + name.length;
continue;
}
}
if (ch === "$") {
const marker = readDollarQuoteMarker(sql, i);
if (marker) {
@ -107,6 +179,123 @@ function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] {
return occurrences;
}
function collectDeclaredSqlServerVariables(sql: string): Set<string> {
const declared = new Set<string>();
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 === "#") {
i = skipLine(sql, i + 1);
continue;
}
if (matchesWord(sql, i, "declare")) {
i = collectDeclareStatementVariables(sql, i + "declare".length, declared);
continue;
}
i += 1;
}
return declared;
}
function collectDeclareStatementVariables(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 (ch === "#") {
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 isLineStatementStart(sql: string, start: number): boolean {
let i = start - 1;
while (i >= 0 && (sql[i] === " " || sql[i] === "\t" || sql[i] === "\r")) i -= 1;
return i >= 0 && sql[i] === "\n";
}
function isSqlStatementKeyword(sql: string, start: number): boolean {
return ["select", "with", "insert", "update", "delete", "merge", "exec", "execute", "set", "if", "while", "begin", "create", "alter", "drop", "truncate"].some((keyword) => matchesWord(sql, start, keyword));
}
function matchesWord(sql: string, start: number, word: string): boolean {
const value = sql.slice(start, start + word.length);
if (value.toLowerCase() !== word) return false;
return !PARAMETER_NAME_CHAR_RE.test(sql[start - 1] ?? "") && !PARAMETER_NAME_CHAR_RE.test(sql[start + word.length] ?? "");
}
function readParameterName(sql: string, start: number): string {
if (!PARAMETER_NAME_START_RE.test(sql[start] ?? "")) return "";
let i = start + 1;
while (i < sql.length && PARAMETER_NAME_CHAR_RE.test(sql[i])) i += 1;
return sql.slice(start, i);
}
function skipQuoted(sql: string, start: number, quote: string): number {
let i = start + 1;
while (i < sql.length) {