From 49e79eb934f0dd67e80af583c6e2ea6d99cc7516 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 26 Jun 2026 14:19:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20SQL=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: 支持 SQL 参数查询 * Fix: 修复前端格式检查 --------- Co-authored-by: staff Co-authored-by: t8y2 <1156263951@qq.com> --- apps/desktop/src/App.vue | 7 +- .../components/editor/SqlParameterDialog.vue | 178 ++++++++++++++++++ .../src/components/layout/AppDialogs.vue | 7 + .../src/composables/useSqlExecution.ts | 36 +++- apps/desktop/src/i18n/locales/en.ts | 17 ++ apps/desktop/src/i18n/locales/es.ts | 17 ++ apps/desktop/src/i18n/locales/it.ts | 17 ++ apps/desktop/src/i18n/locales/ja.ts | 17 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 17 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 17 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 17 ++ .../lib/__tests__/sqlParameterHistory.spec.ts | 47 +++++ .../src/lib/__tests__/sqlParameters.spec.ts | 56 ++++++ apps/desktop/src/lib/sqlParameterHistory.ts | 65 +++++++ apps/desktop/src/lib/sqlParameters.ts | 168 +++++++++++++++++ 15 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/editor/SqlParameterDialog.vue create mode 100644 apps/desktop/src/lib/__tests__/sqlParameterHistory.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/sqlParameters.spec.ts create mode 100644 apps/desktop/src/lib/sqlParameterHistory.ts create mode 100644 apps/desktop/src/lib/sqlParameters.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 65fcc3446..f9b19c2bd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -216,7 +216,7 @@ function promptActiveDatabaseSelection() { toast(t("editor.selectDatabaseRequired"), 2500); } -const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, explainMode } = useSqlExecution({ +const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, showSqlParameterDialog, sqlParameterSourceSql, sqlParameterNames, onSqlParametersConfirm, explainMode } = useSqlExecution({ activeTab, activeConnection, executableSql, @@ -1629,11 +1629,16 @@ onUnmounted(() => { :show-danger-dialog="showDangerDialog" :danger-sql="dangerSql" :suppress-danger-confirm="suppressDangerConfirm" + :show-sql-parameter-dialog="showSqlParameterDialog" + :sql-parameter-source-sql="sqlParameterSourceSql" + :sql-parameter-names="sqlParameterNames" @update:show-connection-dialog="setConnectionDialogOpen" @update:show-settings-dialog="showSettingsDialog = $event" @update:show-danger-dialog="showDangerDialog = $event" @update:suppress-danger-confirm="suppressDangerConfirm = $event" + @update:show-sql-parameter-dialog="showSqlParameterDialog = $event" @danger-confirm="onDangerConfirm" + @sql-parameters-confirm="onSqlParametersConfirm" @connect-started="(name: string) => toast(t('connection.connecting', { name }), 30000)" @connect-succeeded="(name: string) => toast(t('connection.connectSuccess', { name }), 2000)" @connect-failed=" diff --git a/apps/desktop/src/components/editor/SqlParameterDialog.vue b/apps/desktop/src/components/editor/SqlParameterDialog.vue new file mode 100644 index 000000000..ece2938eb --- /dev/null +++ b/apps/desktop/src/components/editor/SqlParameterDialog.vue @@ -0,0 +1,178 @@ + + + diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 6943aa6f4..0782e07a4 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " const ConnectionDialog = defineAsyncComponent(() => import("@/components/connection/ConnectionDialog.vue")); const EditorSettingsDialog = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue")); const DangerConfirmDialog = defineAsyncComponent(() => import("@/components/editor/DangerConfirmDialog.vue")); +const SqlParameterDialog = defineAsyncComponent(() => import("@/components/editor/SqlParameterDialog.vue")); const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue")); const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue")); const DataCompareDialog = defineAsyncComponent(() => import("@/components/diff/DataCompareDialog.vue")); @@ -31,6 +32,9 @@ const props = defineProps<{ showDangerDialog: boolean; dangerSql: string; suppressDangerConfirm: boolean; + showSqlParameterDialog: boolean; + sqlParameterSourceSql: string; + sqlParameterNames: string[]; }>(); const emit = defineEmits<{ @@ -38,7 +42,9 @@ const emit = defineEmits<{ "update:showSettingsDialog": [value: boolean]; "update:showDangerDialog": [value: boolean]; "update:suppressDangerConfirm": [value: boolean]; + "update:showSqlParameterDialog": [value: boolean]; dangerConfirm: []; + sqlParametersConfirm: [sql: string]; connectStarted: [name: string]; connectSucceeded: [name: string]; connectFailed: [message: string]; @@ -119,6 +125,7 @@ watch( @update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)" @confirm="emit('dangerConfirm')" /> + ("explain"); + const showSqlParameterDialog = ref(false); + const sqlParameterSourceSql = ref(""); + const sqlParameterNames = ref([]); async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise { if (typeof source === "string") return source; @@ -72,6 +76,11 @@ export function useSqlExecution(deps: { deps.onMissingDatabase?.(); return; } + if (supportsSqlTemplateParameters(deps.activeConnection.value) && prepareSqlParameterDialog(sql)) return; + await continueExecute(sql); + } + + async function continueExecute(sql: string) { // Redis: block dangerous commands when toggle is on (check each line for multi-line input) if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) { const commands = sql @@ -92,10 +101,19 @@ export function useSqlExecution(deps: { suppressDangerConfirm.value = false; showDangerDialog.value = true; } else { - doExecute(sql); + await doExecute(sql); } } + function prepareSqlParameterDialog(sql: string): boolean { + const parameters = extractSqlParameters(sql); + if (!parameters.length) return false; + sqlParameterSourceSql.value = sql; + sqlParameterNames.value = parameters; + showSqlParameterDialog.value = true; + return true; + } + async function doExecute(sql?: string) { sql ??= await resolvedExecutableSql(); const tab = deps.activeTab.value; @@ -178,6 +196,13 @@ export function useSqlExecution(deps: { await doExecute(sql); } + async function onSqlParametersConfirm(sql: string) { + showSqlParameterDialog.value = false; + sqlParameterSourceSql.value = ""; + sqlParameterNames.value = []; + await continueExecute(sql); + } + return { dangerSql, pendingDangerSql, @@ -188,10 +213,19 @@ export function useSqlExecution(deps: { cancelActiveExecution, tryExplain, onDangerConfirm, + showSqlParameterDialog, + sqlParameterSourceSql, + sqlParameterNames, + onSqlParametersConfirm, explainMode, }; } +function supportsSqlTemplateParameters(connection: ConnectionConfig | undefined): boolean { + if (!connection) return false; + return connection.db_type !== "redis" && connection.db_type !== "mongodb"; +} + function requiresDatabaseSelection(tab: QueryTab, connection: ConnectionConfig | undefined): boolean { if (tab.mode !== "query") return false; if (!connection || tab.database) return false; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0d16f28c2..69d873f17 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1887,6 +1887,23 @@ export default { cancel: "Cancel", confirm: "Execute", }, + sqlParameters: { + title: "SQL Parameters", + description: "Fill values for SQL template placeholders. DBX replaces them before executing the SQL.", + name: "Parameter", + type: "Type", + value: "Value", + valuePlaceholder: "Enter value", + preview: "SQL Preview", + execute: "Execute", + kind: { + string: "String", + number: "Number", + boolean: "Boolean", + null: "NULL", + raw: "Raw SQL", + }, + }, transfer: { title: "Data Transfer", source: "Source", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7748a5ec4..e2f93893a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1539,6 +1539,23 @@ export default { cancel: "Cancelar", confirm: "Ejecutar", }, + sqlParameters: { + title: "Parámetros SQL", + description: "Completa valores para marcadores de plantilla SQL. DBX los reemplaza antes de ejecutar el SQL.", + name: "Parámetro", + type: "Tipo", + value: "Valor", + valuePlaceholder: "Introduce un valor", + preview: "Vista previa SQL", + execute: "Ejecutar", + kind: { + string: "Cadena", + number: "Número", + boolean: "Booleano", + null: "NULL", + raw: "SQL sin procesar", + }, + }, transfer: { title: "Transferencia de datos", source: "Origen", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 912f474b3..f72da7048 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1668,6 +1668,23 @@ export default { cancel: "Annulla", confirm: "Esegui", }, + sqlParameters: { + title: "Parametri SQL", + description: "Compila i valori per i segnaposto del template SQL. DBX li sostituisce prima di eseguire l'SQL.", + name: "Parametro", + type: "Tipo", + value: "Valore", + valuePlaceholder: "Inserisci valore", + preview: "Anteprima SQL", + execute: "Esegui", + kind: { + string: "Stringa", + number: "Numero", + boolean: "Booleano", + null: "NULL", + raw: "SQL grezzo", + }, + }, transfer: { title: "Trasferimento Dati", source: "Sorgente", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f859d0e15..b140cde3f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1799,6 +1799,23 @@ export default { cancel: "キャンセル", confirm: "実行", }, + sqlParameters: { + title: "SQLパラメーター", + description: "SQLテンプレートのプレースホルダーに値を入力します。DBXは実行前にSQLリテラルへ置換します。", + name: "パラメーター", + type: "型", + value: "値", + valuePlaceholder: "値を入力", + preview: "SQLプレビュー", + execute: "実行", + kind: { + string: "文字列", + number: "数値", + boolean: "真偽値", + null: "NULL", + raw: "Raw SQL", + }, + }, transfer: { title: "データ転送", source: "ソース", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1cee85601..d7940bde4 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1679,6 +1679,23 @@ export default { cancel: "Cancelar", confirm: "Executar", }, + sqlParameters: { + title: "Parâmetros SQL", + description: "Preencha valores para marcadores de template SQL. O DBX substitui antes de executar o SQL.", + name: "Parâmetro", + type: "Tipo", + value: "Valor", + valuePlaceholder: "Digite o valor", + preview: "Prévia SQL", + execute: "Executar", + kind: { + string: "Texto", + number: "Número", + boolean: "Booleano", + null: "NULL", + raw: "SQL bruto", + }, + }, transfer: { title: "Transferência de Dados", source: "Origem", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8a173d4ad..356748cc3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1886,6 +1886,23 @@ export default { cancel: "取消", confirm: "执行", }, + sqlParameters: { + title: "SQL 参数", + description: "为 SQL 模板占位符填写参数值,DBX 会在执行前替换为 SQL 字面量。", + name: "参数名", + type: "类型", + value: "参数值", + valuePlaceholder: "输入参数值", + preview: "SQL 预览", + execute: "执行", + kind: { + string: "字符串", + number: "数字", + boolean: "布尔值", + null: "NULL", + raw: "原始 SQL", + }, + }, transfer: { title: "数据传输", source: "源", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f50590ee9..d519334a9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1670,6 +1670,23 @@ export default { cancel: "取消", confirm: "執行", }, + sqlParameters: { + title: "SQL 參數", + description: "為 SQL 模板佔位符填寫參數值,DBX 會在執行前替換為 SQL 字面量。", + name: "參數名", + type: "類型", + value: "參數值", + valuePlaceholder: "輸入參數值", + preview: "SQL 預覽", + execute: "執行", + kind: { + string: "字串", + number: "數字", + boolean: "布林值", + null: "NULL", + raw: "原始 SQL", + }, + }, transfer: { title: "資料傳輸", source: "來源", diff --git a/apps/desktop/src/lib/__tests__/sqlParameterHistory.spec.ts b/apps/desktop/src/lib/__tests__/sqlParameterHistory.spec.ts new file mode 100644 index 000000000..736cda3ea --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sqlParameterHistory.spec.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadSqlParameterHistory, MAX_SQL_PARAMETER_HISTORY, rememberSqlParameterValue } from "../sqlParameterHistory"; + +const storage = new Map(); + +beforeEach(() => { + storage.clear(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }); +}); + +describe("sqlParameterHistory", () => { + it("stores recent values by parameter name", () => { + rememberSqlParameterValue("start_date", { kind: "string", value: "2026-01-01" }); + rememberSqlParameterValue("start_date", { kind: "string", value: "2026-01-02" }); + + expect(loadSqlParameterHistory("start_date")).toEqual([ + { kind: "string", value: "2026-01-02" }, + { kind: "string", value: "2026-01-01" }, + ]); + }); + + it("deduplicates by type and value and moves the latest value to the front", () => { + rememberSqlParameterValue("min_id", { kind: "number", value: "1" }); + rememberSqlParameterValue("min_id", { kind: "number", value: "2" }); + rememberSqlParameterValue("min_id", { kind: "number", value: "1" }); + + expect(loadSqlParameterHistory("min_id")).toEqual([ + { kind: "number", value: "1" }, + { kind: "number", value: "2" }, + ]); + }); + + it("matches parameter names case-insensitively and limits history size", () => { + for (let i = 0; i < MAX_SQL_PARAMETER_HISTORY + 2; i += 1) { + rememberSqlParameterValue("UserId", { kind: "number", value: String(i) }); + } + + const history = loadSqlParameterHistory("userId"); + expect(history).toHaveLength(MAX_SQL_PARAMETER_HISTORY); + expect(history[0]).toEqual({ kind: "number", value: String(MAX_SQL_PARAMETER_HISTORY + 1) }); + expect(history.at(-1)).toEqual({ kind: "number", value: "2" }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sqlParameters.spec.ts new file mode 100644 index 000000000..25b005454 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sqlParameters.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { extractSqlParameters, sqlParameterLiteral, substituteSqlParameters } from "../sqlParameters"; + +describe("extractSqlParameters", () => { + it("extracts unique template parameters in order", () => { + const sql = "select * from t where pt_dt between ${start_date} and ${end_date} or pt_dt = ${start_date}"; + expect(extractSqlParameters(sql)).toEqual(["start_date", "end_date"]); + }); + + it("ignores placeholders inside strings, quoted identifiers, and comments", () => { + const sql = ` + select '\${quoted}' as a, "\${identifier}" as b, \`\${mysql_identifier}\` + -- \${line_comment} + # \${hash_comment} + /* \${block_comment} */ + from t + where id = \${id} + `; + expect(extractSqlParameters(sql)).toEqual(["id"]); + }); + + it("ignores placeholders inside Postgres dollar-quoted strings", () => { + const sql = "select $$ ${body_param} $$, $tag$ ${tag_param} $tag$, ${real_param}"; + expect(extractSqlParameters(sql)).toEqual(["real_param"]); + }); +}); + +describe("substituteSqlParameters", () => { + it("replaces placeholders with SQL literals", () => { + const sql = "select * from t where dt >= ${start_date} and amount > ${amount} and enabled = ${enabled}"; + expect( + substituteSqlParameters(sql, { + start_date: { kind: "string", value: "2026-06-26" }, + amount: { kind: "number", value: "100.50" }, + enabled: { kind: "boolean", value: "true" }, + }), + ).toBe("select * from t where dt >= '2026-06-26' and amount > 100.50 and enabled = TRUE"); + }); + + it("escapes string values and supports null and raw SQL", () => { + const sql = "select ${name}, ${empty_value}, ${expression}"; + expect( + substituteSqlParameters(sql, { + name: { kind: "string", value: "O'Reilly" }, + empty_value: { kind: "null", value: "" }, + expression: { kind: "raw", value: "current_date" }, + }), + ).toBe("select 'O''Reilly', NULL, current_date"); + }); +}); + +describe("sqlParameterLiteral", () => { + it("falls back to quoted strings for invalid boolean input", () => { + expect(sqlParameterLiteral({ kind: "boolean", value: "maybe" })).toBe("'maybe'"); + }); +}); diff --git a/apps/desktop/src/lib/sqlParameterHistory.ts b/apps/desktop/src/lib/sqlParameterHistory.ts new file mode 100644 index 000000000..7cca36c8a --- /dev/null +++ b/apps/desktop/src/lib/sqlParameterHistory.ts @@ -0,0 +1,65 @@ +import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; +import type { SqlParameterInput } from "@/lib/sqlParameters"; + +const STORAGE_KEY = "dbx-sql-parameter-history"; +export const MAX_SQL_PARAMETER_HISTORY = 8; + +interface StoredSqlParameterHistory { + version: 1; + parameters: Record; +} + +function emptyHistory(): StoredSqlParameterHistory { + return { version: 1, parameters: {} }; +} + +function readHistory(): StoredSqlParameterHistory { + const raw = safeLocalStorageGet(STORAGE_KEY); + if (!raw) return emptyHistory(); + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== 1 || !parsed.parameters || typeof parsed.parameters !== "object") return emptyHistory(); + return { version: 1, parameters: parsed.parameters }; + } catch { + return emptyHistory(); + } +} + +function writeHistory(history: StoredSqlParameterHistory) { + safeLocalStorageSet(STORAGE_KEY, JSON.stringify(history)); +} + +function normalizeParameterName(name: string): string { + return name.trim().toLowerCase(); +} + +function normalizeInput(input: SqlParameterInput): SqlParameterInput | null { + if (input.kind === "null") return { kind: "null", value: "NULL" }; + const value = input.value.trim(); + if (!value) return null; + return { kind: input.kind, value }; +} + +export function loadSqlParameterHistory(name: string): SqlParameterInput[] { + return (readHistory().parameters[normalizeParameterName(name)] ?? []).slice(0, MAX_SQL_PARAMETER_HISTORY); +} + +export function rememberSqlParameterValue(name: string, input: SqlParameterInput): SqlParameterInput[] { + const normalized = normalizeInput(input); + if (!normalized) return loadSqlParameterHistory(name); + + const history = readHistory(); + const key = normalizeParameterName(name); + const previous = history.parameters[key] ?? []; + history.parameters[key] = [normalized, ...previous.filter((entry) => entry.value !== normalized.value || entry.kind !== normalized.kind)].slice(0, MAX_SQL_PARAMETER_HISTORY); + writeHistory(history); + return history.parameters[key]; +} + +export function rememberSqlParameterValues(values: Record): Record { + const result: Record = {}; + for (const [name, input] of Object.entries(values)) { + result[name] = rememberSqlParameterValue(name, input); + } + return result; +} diff --git a/apps/desktop/src/lib/sqlParameters.ts b/apps/desktop/src/lib/sqlParameters.ts new file mode 100644 index 000000000..607d2aa82 --- /dev/null +++ b/apps/desktop/src/lib/sqlParameters.ts @@ -0,0 +1,168 @@ +export type SqlParameterValueKind = "string" | "number" | "boolean" | "null" | "raw"; + +export interface SqlParameterInput { + kind: SqlParameterValueKind; + value: string; +} + +interface ParameterOccurrence { + name: string; + start: number; + end: number; +} + +const PARAMETER_NAME_RE = /^[\p{L}_][\p{L}\p{N}_]*$/u; + +export function extractSqlParameters(sql: string): string[] { + const names = new Set(); + for (const occurrence of findSqlParameterOccurrences(sql)) { + names.add(occurrence.name); + } + return [...names]; +} + +export function substituteSqlParameters(sql: string, values: Record): string { + const occurrences = findSqlParameterOccurrences(sql); + if (!occurrences.length) return sql; + + let result = ""; + let cursor = 0; + for (const occurrence of occurrences) { + result += sql.slice(cursor, occurrence.start); + result += sqlParameterLiteral(values[occurrence.name] ?? { kind: "string", value: "" }); + cursor = occurrence.end; + } + result += sql.slice(cursor); + return result; +} + +export function sqlParameterLiteral(input: SqlParameterInput): string { + if (input.kind === "null") return "NULL"; + const raw = input.value; + if (input.kind === "raw") return raw.trim() || "NULL"; + if (input.kind === "number") return raw.trim() || "NULL"; + if (input.kind === "boolean") return normalizeBooleanLiteral(raw); + return quoteSqlString(raw); +} + +function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] { + const occurrences: ParameterOccurrence[] = []; + 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 === "#") { + i = skipLine(sql, i + 1); + continue; + } + if (ch === "/" && next === "*") { + i = skipBlockComment(sql, i + 2); + 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 }); + i = end + 1; + continue; + } + } + } + if (ch === "$") { + const marker = readDollarQuoteMarker(sql, i); + if (marker) { + dollarQuoteEnd = marker; + i += marker.length; + continue; + } + } + i += 1; + } + + return occurrences; +} + +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] ?? ""; +} + +function quoteSqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function normalizeBooleanLiteral(value: string): string { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "t" || normalized === "yes" || normalized === "y" || normalized === "1") return "TRUE"; + if (normalized === "false" || normalized === "f" || normalized === "no" || normalized === "n" || normalized === "0") return "FALSE"; + return quoteSqlString(value); +}