[Feat]:实现SQL变量替换 (#2597)
This commit is contained in:
parent
987b0fd263
commit
b118ca31ed
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2066,6 +2066,7 @@ export default withEnglishFallback({
|
|||
title: "SQLパラメーター",
|
||||
description: "SQLテンプレートのプレースホルダーに値を入力します。DBXは実行前にSQLリテラルへ置換します。",
|
||||
name: "パラメーター",
|
||||
syntax: "Syntax",
|
||||
type: "型",
|
||||
value: "値",
|
||||
valuePlaceholder: "値を入力",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2124,6 +2124,7 @@ export default withEnglishFallback({
|
|||
title: "SQL 参数",
|
||||
description: "为 SQL 模板占位符填写参数值,DBX 会在执行前替换为 SQL 字面量。",
|
||||
name: "参数名",
|
||||
syntax: "Syntax",
|
||||
type: "类型",
|
||||
value: "参数值",
|
||||
valuePlaceholder: "输入参数值",
|
||||
|
|
|
|||
|
|
@ -1970,6 +1970,7 @@ export default withEnglishFallback({
|
|||
title: "SQL 參數",
|
||||
description: "為 SQL 模板佔位符填寫參數值,DBX 會在執行前替換為 SQL 字面量。",
|
||||
name: "參數名",
|
||||
syntax: "Syntax",
|
||||
type: "類型",
|
||||
value: "參數值",
|
||||
valuePlaceholder: "輸入參數值",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue