feat: 支持 SQL 参数查询
* Fix: 支持 SQL 参数查询 * Fix: 修复前端格式检查 --------- Co-authored-by: staff <staff@qimaos-MacBook-Pro.local> Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
parent
9bed864e61
commit
49e79eb934
|
|
@ -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="
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { Braces } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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/sqlParameterHistory";
|
||||
import { substituteSqlParameters, type SqlParameterInput, type SqlParameterValueKind } from "@/lib/sqlParameters";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { highlight } = useSqlHighlighter();
|
||||
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
sql: string;
|
||||
parameters: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
execute: [sql: string];
|
||||
}>();
|
||||
|
||||
const values = ref<Record<string, SqlParameterInput>>({});
|
||||
const histories = ref<Record<string, SqlParameterInput[]>>({});
|
||||
const activeHistoryName = ref("");
|
||||
let closeHistoryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const parameterKinds: SqlParameterValueKind[] = ["string", "number", "boolean", "null", "raw"];
|
||||
|
||||
const resolvedSql = computed(() => substituteSqlParameters(props.sql, values.value));
|
||||
const highlightedSql = computed(() => highlight(resolvedSql.value));
|
||||
|
||||
watch(
|
||||
() => [open.value, props.parameters] as const,
|
||||
([isOpen]) => {
|
||||
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: "" };
|
||||
}
|
||||
values.value = next;
|
||||
histories.value = nextHistories;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function updateKind(name: string, kind: SqlParameterValueKind) {
|
||||
const current = values.value[name] ?? { value: "" };
|
||||
const value = kind === "null" ? "NULL" : current.kind === "null" ? "" : current.value;
|
||||
values.value[name] = { ...current, kind, value };
|
||||
}
|
||||
|
||||
function updateValue(name: string, value: string) {
|
||||
const matchedHistory = histories.value[name]?.find((entry) => entry.value === value);
|
||||
values.value[name] = { ...(values.value[name] ?? { kind: "string" }), ...(matchedHistory ? { kind: matchedHistory.kind } : {}), value };
|
||||
}
|
||||
|
||||
function filteredSqlParameterHistory(name: string): SqlParameterInput[] {
|
||||
const history = histories.value[name] ?? [];
|
||||
const query = values.value[name]?.value?.trim().toLowerCase() ?? "";
|
||||
if (!query) return history;
|
||||
return history.filter((entry) => entry.value.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function focusParameterInput(name: string, event: FocusEvent) {
|
||||
if (closeHistoryTimer) clearTimeout(closeHistoryTimer);
|
||||
activeHistoryName.value = name;
|
||||
const input = event.target as HTMLInputElement;
|
||||
void nextTick(() => input.focus());
|
||||
}
|
||||
|
||||
function closeParameterHistory(name: string) {
|
||||
closeHistoryTimer = setTimeout(() => {
|
||||
if (activeHistoryName.value === name) activeHistoryName.value = "";
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function selectHistoryEntry(name: string, entry: SqlParameterInput) {
|
||||
if (closeHistoryTimer) clearTimeout(closeHistoryTimer);
|
||||
values.value[name] = { ...entry };
|
||||
activeHistoryName.value = "";
|
||||
}
|
||||
|
||||
function execute() {
|
||||
histories.value = { ...histories.value, ...rememberSqlParameterValues(values.value) };
|
||||
open.value = false;
|
||||
emit("execute", resolvedSql.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Braces class="h-5 w-5 text-primary" />
|
||||
{{ t("sqlParameters.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid max-h-[calc(86vh-8rem)] gap-4 overflow-y-auto pr-1">
|
||||
<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>{{ t("sqlParameters.name") }}</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)">
|
||||
<SelectTrigger class="h-8 bg-background text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="kind in parameterKinds" :key="kind" :value="kind">
|
||||
{{ t(`sqlParameters.kind.${kind}`) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="relative min-w-0">
|
||||
<Popover :open="activeHistoryName === name && filteredSqlParameterHistory(name).length > 0">
|
||||
<PopoverAnchor as-child>
|
||||
<Input
|
||||
:model-value="values[name]?.value || ''"
|
||||
class="h-8 bg-background font-mono text-xs"
|
||||
:disabled="values[name]?.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))"
|
||||
/>
|
||||
</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)"
|
||||
: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)"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 grid gap-2">
|
||||
<div class="text-xs font-medium text-muted-foreground">{{ t("sqlParameters.preview") }}</div>
|
||||
<pre class="max-h-48 min-w-0 overflow-auto rounded-md bg-muted px-3 py-3 text-xs font-mono whitespace-pre" v-html="highlightedSql" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button @click="execute">{{ t("sqlParameters.execute") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -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')"
|
||||
/>
|
||||
<SqlParameterDialog v-if="showSqlParameterDialog" :open="showSqlParameterDialog" :sql="sqlParameterSourceSql" :parameters="sqlParameterNames" @update:open="emit('update:showSqlParameterDialog', $event)" @execute="emit('sqlParametersConfirm', $event)" />
|
||||
<DataTransferDialog v-if="dialogs.showTransferDialog.value" v-model:open="dialogs.showTransferDialog.value" :prefill-connection-id="dialogs.transferPrefillConnectionId.value" :prefill-database="dialogs.transferPrefillDatabase.value" />
|
||||
<SchemaDiffDialog v-if="dialogs.showSchemaDiffDialog.value" v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" :prefill-schema="dialogs.schemaDiffPrefillSchema.value" />
|
||||
<DataCompareDialog
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { classifySqlActivityKind } from "@/lib/historyActivityKind";
|
|||
import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh";
|
||||
import { classifyRedisCommandSafety, firstRedisCommandToken } from "@/lib/redisCommandSafety";
|
||||
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sqlExecutionTarget";
|
||||
import { extractSqlParameters } from "@/lib/sqlParameters";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
|
||||
|
|
@ -56,6 +57,9 @@ export function useSqlExecution(deps: {
|
|||
const showDangerDialog = ref(false);
|
||||
const suppressDangerConfirm = ref(false);
|
||||
const explainMode = ref<"explain" | "autotrace">("explain");
|
||||
const showSqlParameterDialog = ref(false);
|
||||
const sqlParameterSourceSql = ref("");
|
||||
const sqlParameterNames = ref<string[]>([]);
|
||||
|
||||
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<string> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "ソース",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "源",
|
||||
|
|
|
|||
|
|
@ -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: "來源",
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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'");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, SqlParameterInput[]>;
|
||||
}
|
||||
|
||||
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<StoredSqlParameterHistory>;
|
||||
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<string, SqlParameterInput>): Record<string, SqlParameterInput[]> {
|
||||
const result: Record<string, SqlParameterInput[]> = {};
|
||||
for (const [name, input] of Object.entries(values)) {
|
||||
result[name] = rememberSqlParameterValue(name, input);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
for (const occurrence of findSqlParameterOccurrences(sql)) {
|
||||
names.add(occurrence.name);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
export function substituteSqlParameters(sql: string, values: Record<string, SqlParameterInput>): 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);
|
||||
}
|
||||
Loading…
Reference in New Issue