feat(editor): preview dml changes as readonly select

This commit is contained in:
Ryan Liu authored and GitHub committed 2026-09-01 12:33:59 +08:00
1 parent d59b86bd57
commit 333c3a3f44
25 files changed
+848 -4

No files matched your search

+8
View File
@@ -281,6 +281,7 @@ const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
const selectedSql = ref("");
const cursorPos = ref(0);
const previewChangesAvailable = ref(false);
const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
const compressSqlRequest = ref<{ id: number; tabId: string } | null>(null);
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
@@ -490,6 +491,10 @@ function requestActiveEditorExecuteInNewResultTab() {
void tryExecuteInNewResultTab();
}
function requestActiveEditorPreviewChanges() {
void contentAreaRef.value?.requestQueryEditorPreviewChanges?.();
}
const multiExecuteDatabaseType = ref<DatabaseType>();
const multiExecuteInitialTargets = ref<Array<{ connectionId: string; catalog?: string; database: string; schema?: string }>>([]);
const multiExecuteLaunchId = ref(0);
@@ -3102,6 +3107,7 @@ onUnmounted(() => {
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:can-preview-changes="previewChangesAvailable"
:explain-mode="explainMode"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
:sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase"
@@ -3123,6 +3129,7 @@ onUnmounted(() => {
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
@execute-pointer-down="captureActiveEditorExecutionSnapshot()"
@execute="requestActiveEditorExecute($event)"
@preview-changes="requestActiveEditorPreviewChanges()"
@multi-execute="requestMultiDbExecute()"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@@ -3163,6 +3170,7 @@ onUnmounted(() => {
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"
@editor-selection-change="(v: string) => (selectedSql = v)"
@editor-cursor-change="(p: number) => (cursorPos = p)"
@preview-changes-available="(v: boolean) => (previewChangesAvailable = v)"
@editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)"
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
@format-error="toast(t('toolbar.formatSqlFailed'))"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
import { AlignLeft, Camera, CaseLower, CaseUpper, ClipboardPaste, Code2, Download, FileCode, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
import { AlignLeft, Camera, CaseLower, CaseUpper, ClipboardPaste, Code2, Download, Eye, FileCode, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { Transaction, StateEffect } from "@codemirror/state";
@@ -18,6 +18,7 @@ import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnaps
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
import { canFormatSqlForDatabaseType, formatSqlForDisplay, formatSqlForEditing, compressSqlText, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
@@ -223,6 +224,7 @@ const emit = defineEmits<{
"update:modelValue": [value: string];
selectionChange: [value: string];
cursorChange: [pos: number];
previewChangesAvailable: [value: boolean];
formatError: [message: string];
execute: [source: SqlExecutionOverride];
executeInNewResultTab: [source: SqlExecutionOverride];
@@ -364,6 +366,7 @@ const isGestureZooming = ref(false);
const searchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const selectedSql = ref("");
const executableSql = ref("");
const previewContextSql = ref("");
const contextObjectTarget = ref<SqlObjectNavigationTarget | null>(null);
interface SelectStarExpansionTarget {
@@ -1066,9 +1069,10 @@ function insertLineBelow(currentView: EditorViewType): boolean {
return true;
}
function syncContextMenuState(currentView: EditorViewType, starPosition?: number) {
function syncContextMenuState(currentView: EditorViewType, starPosition?: number, previewPosition?: number) {
selectedSql.value = selectedSqlFromView(currentView);
executableSql.value = executableSqlFromView(currentView);
previewContextSql.value = resolvePreviewDmlCandidate(previewPosition);
selectStarExpansionTarget.value = selectStarExpansionTargetForView(currentView, starPosition);
}
@@ -1152,7 +1156,8 @@ function selectStarExpansionTargetForView(currentView: EditorViewType, position?
function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEvent) {
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
syncContextMenuState(currentView, pos ?? undefined);
// 预览按“右键点击处”解析当前语句(执行按光标处),右键处与光标一致时才直觉一致。
syncContextMenuState(currentView, pos ?? undefined, pos ?? undefined);
if (pos == null) {
contextObjectTarget.value = null;
return;
@@ -1424,6 +1429,61 @@ function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
emit("exportQuery", { sql, format, columnComments: undefined });
}
// 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。
// 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。
function resolvePreviewDmlCandidate(position?: number): string {
const currentView = view.value;
if (!currentView) return "";
const selection = currentView.state.selection.main;
if (!selection.empty) {
const text = currentView.state.sliceDoc(selection.from, selection.to);
return looksLikeDmlStatement(text) ? text : "";
}
const doc = currentView.state.doc.toString();
const cursorPos = position ?? selection.head;
const parameterOptions = sqlStatementParameterOptions();
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType, parameterOptions);
const cursorCandidate = candidates.find((item) => item.supportedKinds.includes("cursor"));
return cursorCandidate && looksLikeDmlStatement(cursorCandidate.sql) ? cursorCandidate.sql : "";
}
// 「预览变更」:把当前 DML 语句改写为只读 SELECT,作为新结果标签执行(干跑,不写库)。
async function requestPreviewChanges(stackSql?: string) {
let sql = (stackSql ?? "").trim();
// 永远只预览“单条语句”:禁用整篇文档回退(那会包含多条语句)。
if (!sql) sql = resolvePreviewDmlCandidate();
if (!sql) {
toast(t("editor.previewChangesNoStatement"), 3000);
return false;
}
try {
const identifierQuote = props.connectionId ? connectionStore.connectionIdentifierQuote?.(props.connectionId) : undefined;
// 第一次:生成基础预览 SELECT,并拿到目标表引用。
let preview = await api.buildDmlChangePreviewSql({ sql, databaseType: props.databaseType, identifierQuote });
// 单表 UPDATE:拉取目标表列元数据,让「新值」列紧跟其原值列(交错展开)。
if (preview.tables.length === 1 && props.connectionId && props.database) {
const tableRef = preview.tables[0];
if (tableRef.table) {
const columns = await api
.getColumns(props.connectionId, props.database, tableRef.schema ?? "", tableRef.table, tableRef.catalog)
.then((infos) => infos.map((column) => column.name))
.catch(() => undefined);
if (columns?.length) {
preview = await api.buildDmlChangePreviewSql({ sql, databaseType: props.databaseType, identifierQuote, columns });
}
}
}
// 前置注释标注干跑预览(引擎会忽略注释),并在新结果标签中展示受影响行 + 新值列。
emit("executeInNewResultTab", `/* ${t("editor.previewChangesComment", { operation: preview.operation })} */\n${preview.sql}`);
return true;
} catch (error: any) {
// http 层抛 BackendErrorException(Error),tauri 层拒绝时是 String。
const message = error instanceof Error ? error.message : typeof error === "string" ? error : t("editor.previewChangesFailed");
toast(message, 4000);
return false;
}
}
async function copySelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
@@ -1776,6 +1836,12 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
icon: Play,
shortcut: shortcuts.executeSqlInNewResultTab,
},
{
label: t("editor.previewChanges"),
action: () => void requestPreviewChanges(previewContextSql.value),
disabled: !previewContextSql.value,
icon: Eye,
},
{
label: t("editor.contextMenu.export"),
icon: Download,
@@ -5769,6 +5835,7 @@ onMounted(async () => {
syncContextMenuState(update.view);
emit("selectionChange", selectedSqlFromView(update.view));
emit("cursorChange", update.state.selection.main.head);
emit("previewChangesAvailable", !!previewContextSql.value);
latestSelection = readEditorSelection(update.view);
if (editorIsActive) emitEditorSelection(latestSelection);
}
@@ -6114,6 +6181,7 @@ onMounted(async () => {
restoreEditorViewport();
syncContextMenuState(view.value);
emit("previewChangesAvailable", !!previewContextSql.value);
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
syncEditorDiagnosticCssVars();
registerTableReferenceDropListener();
@@ -6602,6 +6670,7 @@ defineExpose({
shouldBlockExecutionShortcut,
requestExecute,
requestExecuteInNewResultTab,
requestPreviewChanges,
captureExecutionSnapshot,
pasteClipboardAsSqlInCondition,
focusStatementRange,
@@ -223,6 +223,7 @@ const emit = defineEmits<{
editorUpdate: [tabId: string, value: string];
editorSelectionChange: [value: string];
editorCursorChange: [pos: number];
previewChangesAvailable: [value: boolean];
editorViewportChange: [tabId: string, viewport: { scrollTop: number; scrollLeft: number }];
editorSelectionStateChange: [tabId: string, selection: { anchor: number; head: number }];
formatError: [];
@@ -1131,6 +1132,10 @@ function requestQueryEditorExecuteInNewResultTab() {
return queryEditorRef.value?.requestExecuteInNewResultTab();
}
function requestQueryEditorPreviewChanges(stackSql?: string) {
return queryEditorRef.value?.requestPreviewChanges?.(stackSql);
}
function shouldBlockQueryEditorExecutionShortcut(event: KeyboardEvent) {
return queryEditorRef.value?.shouldBlockExecutionShortcut?.(event) ?? false;
}
@@ -1185,6 +1190,7 @@ defineExpose({
requestQueryEditorExecute,
captureQueryEditorExecutionSnapshot,
requestQueryEditorExecuteInNewResultTab,
requestQueryEditorPreviewChanges,
shouldBlockQueryEditorExecutionShortcut,
acceptQueryEditorExecutionViewport,
cancelQueryEditorExecutionViewport,
@@ -1241,6 +1247,7 @@ defineExpose({
@selection-change="emit('editorSelectionChange', $event)"
@send-selection-to-ai="emit('sendSelectionToAi', $event)"
@cursor-change="emit('editorCursorChange', $event)"
@preview-changes-available="emit('previewChangesAvailable', $event)"
@viewport-change="emit('editorViewportChange', activeTab.id, $event)"
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
@format-error="emit('formatError')"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2 } from "@lucide/vue";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Eye } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@@ -21,12 +21,15 @@ import { connectionIsDorisFamilyCatalogCapable } from "@/lib/database/databaseFe
import { hexToRgba } from "@/lib/common/color";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
import type { QueryTab, ConnectionConfig } from "@/types/database";
const props = defineProps<{
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
/** 来自 QueryEditor 的实时“当前语句是否为可预览 DML”信号;未提供时退回可编辑文档启发式。 */
canPreviewChanges?: boolean;
explainMode?: string;
blockDangerousRedisCommands?: boolean;
sqlKeywordCase: "preserve" | "upper" | "lower";
@@ -46,6 +49,7 @@ const emit = defineEmits<{
execute: [source: "pointer" | "keyboard"];
executePointerDown: [];
cancel: [];
previewChanges: [];
explain: [];
"update:explainMode": [mode: "explain" | "autotrace"];
formatSql: [];
@@ -131,6 +135,11 @@ const schemaDatabaseKey = computed(() => props.activeTab.database || (isSingleDb
const saveTooltip = computed(() => (props.activeTab.objectSource ? t("objects.saveSource") : t("toolbar.saveSql")));
const executeShortcutDisplay = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.executeSql));
const executeShortcutTooltip = computed(() => t("toolbar.executeShortcut", { shortcut: executeShortcutDisplay.value }));
// executableSql 在无选区时可能是整篇文档;只要有 DML 语句出现就显示预览按钮,
// 具体"当前语句"由编辑器(QueryEditor)按执行模式解析。
const DML_KEYWORD_RE = /(^|\s)(update|insert|delete)\s/i;
const canPreviewDml = computed(() => looksLikeDmlStatement(props.executableSql) || DML_KEYWORD_RE.test(props.executableSql));
const previewButtonVisible = computed(() => props.canPreviewChanges ?? canPreviewDml.value);
// DM calls it autotrace, Postgres EXPLAIN ANALYZE, SQL Server the actual execution
// plan (SET STATISTICS XML); all three execute the statement.
const supportsExplainAnalyze = computed(() => {
@@ -301,6 +310,14 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ activeTab.isExecuting ? t("toolbar.stopQuery") : executeShortcutTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="previewButtonVisible">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700" :disabled="activeTab.isExecuting || activeTab.isCancelling" :aria-label="t('editor.previewChanges')" @mousedown.prevent @click="emit('previewChanges')">
<Eye class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("editor.previewChanges") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="supportsExplain">
<TooltipTrigger as-child>
<Button
+4
View File
@@ -1340,6 +1340,10 @@ export default {
JSON_ARRAY: "Creates a JSON array",
},
},
previewChanges: "Preview changes",
previewChangesComment: "Change preview (dry run) for {operation} statement",
previewChangesFailed: "Change preview failed.",
previewChangesNoStatement: "Select or place the cursor on an UPDATE / INSERT / DELETE statement to preview its changes.",
contextMenu: {
executeSelection: "Execute selection",
executeCurrent: "Execute SQL",
+4
View File
@@ -1385,6 +1385,10 @@ export default withEnglishFallback({
selectFirst: "Seleccione el texto de la lista",
},
connectionOnlyTarget: "Objetivo a nivel de conexión",
previewChanges: "Previsualizar cambios",
previewChangesComment: "Previsualización de cambios (simulación): sentencia {operation}",
previewChangesFailed: "Error al previsualizar los cambios.",
previewChangesNoStatement: "Seleccione o coloque el cursor sobre una sentencia UPDATE / INSERT / DELETE para previsualizar los cambios.",
},
tabs: {
sql: "SQL",
+4
View File
@@ -1383,6 +1383,10 @@ export default withEnglishFallback({
selectFirst: "Seleziona il testo della lista",
},
connectionOnlyTarget: "Target a livello di connessione",
previewChanges: "Anteprima modifiche",
previewChangesComment: "Anteprima modifiche (prova a secco): istruzione {operation}",
previewChangesFailed: "Anteprima modifiche non riuscita.",
previewChangesNoStatement: "Seleziona o posiziona il cursore su un'istruzione UPDATE / INSERT / DELETE per visualizzare l'anteprima delle modifiche.",
},
tabs: {
sql: "SQL",
+4
View File
@@ -1404,6 +1404,10 @@ export default withEnglishFallback({
selectFirst: "リストテキストを選択してください",
},
connectionOnlyTarget: "接続レベル目標",
previewChanges: "変更をプレビュー",
previewChangesComment: "変更プレビュー(ドライラン):{operation} ステートメント",
previewChangesFailed: "変更プレビューに失敗しました。",
previewChangesNoStatement: "変更をプレビューするには、UPDATE / INSERT / DELETE ステートメントを選択するか、その上にカーソルを置いてください。",
},
tabs: {
sql: "SQL",
+4
View File
@@ -1286,6 +1286,10 @@ export default withEnglishFallback({
currentCommand: "현재 명령",
allCommands: "전체 명령",
},
previewChanges: "변경 사항 미리 보기",
previewChangesComment: "변경 미리 보기(드라이 런): {operation} 문",
previewChangesFailed: "변경 미리 보기 실패.",
previewChangesNoStatement: "변경 사항을 미리 보려면 UPDATE / INSERT / DELETE 문을 선택하거나 커서를 해당 문에 놓으십시오.",
},
tabs: {
sql: "SQL",
+4
View File
@@ -1384,6 +1384,10 @@ export default withEnglishFallback({
selectFirst: "Selecione o texto da lista primeiro",
},
connectionOnlyTarget: "Alvo em nível de conexão",
previewChanges: "Pré-visualizar alterações",
previewChangesComment: "Pré-visualização de alterações (simulação): instrução {operation}",
previewChangesFailed: "Falha ao pré-visualizar alterações.",
previewChangesNoStatement: "Selecione ou coloque o cursor em uma instrução UPDATE / INSERT / DELETE para pré-visualizar as alterações.",
},
tabs: {
sql: "SQL",
+4
View File
@@ -1264,6 +1264,10 @@ export default withEnglishFallback({
JSON_ARRAY: "创建 JSON 数组",
},
},
previewChanges: "预览变更",
previewChangesComment: "变更预览(干跑):{operation} 语句",
previewChangesFailed: "变更预览失败。",
previewChangesNoStatement: "请选中或把光标放在一条 UPDATE / INSERT / DELETE 语句上以预览变更。",
contextMenu: {
executeSelection: "执行选中 SQL",
executeCurrent: "执行 SQL",
+4
View File
@@ -1383,6 +1383,10 @@ export default withEnglishFallback({
selectFirst: "請選擇清單文字",
},
connectionOnlyTarget: "連線級目標",
previewChanges: "預覽變更",
previewChangesComment: "變更預覽(乾跑):{operation} 語句",
previewChangesFailed: "變更預覽失敗。",
previewChangesNoStatement: "請選取或將游標放在一條 UPDATE / INSERT / DELETE 語句上以預覽變更。",
},
tabs: {
sql: "SQL",
+1
View File
@@ -272,6 +272,7 @@ export const prepareDataGridSave = forward("prepareDataGridSave");
export const extractDataGridSelection = forward("extractDataGridSelection");
export const buildDataGridCopyUpdateStatements = forward("buildDataGridCopyUpdateStatements");
export const buildDataGridCopyInsertStatement = forward("buildDataGridCopyInsertStatement");
export const buildDmlChangePreviewSql = forward("buildDmlChangePreviewSql");
export const buildDataGridContextFilterCondition = forward("buildDataGridContextFilterCondition");
export const buildDataGridColumnValueFilterCondition = forward("buildDataGridColumnValueFilterCondition");
export const buildDataGridColumnValuesFilterCondition = forward("buildDataGridColumnValuesFilterCondition");
+5
View File
@@ -165,6 +165,7 @@ import type {
DataGridSaveStatementOptions,
HiveTablePropertiesSqlOptions,
} from "@/lib/dataGrid/dataGridSql";
import type { DmlChangePreviewSqlOptions, DmlChangePreviewSqlResult } from "@/lib/sql/dmlChangePreview";
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
@@ -1527,6 +1528,10 @@ export async function buildDataGridCopyInsertStatement(options: DataGridCopyInse
return result ?? undefined;
}
export async function buildDmlChangePreviewSql(options: DmlChangePreviewSqlOptions): Promise<DmlChangePreviewSqlResult> {
return post("/api/query/build-dml-change-preview-sql", { options });
}
export async function buildDataGridContextFilterCondition(options: DataGridContextFilterConditionOptions): Promise<string | undefined> {
const result = await post<string | null>("/api/query/build-data-grid-context-filter-condition", { options });
return result ?? undefined;
+5
View File
@@ -84,6 +84,7 @@ import type {
DataGridSaveStatementOptions,
HiveTablePropertiesSqlOptions,
} from "@/lib/dataGrid/dataGridSql";
import type { DmlChangePreviewSqlOptions, DmlChangePreviewSqlResult } from "@/lib/sql/dmlChangePreview";
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, SchemaSyncSqlPlan, SelectedSchemaDiffInput, GenerateSchemaSyncPlanOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
@@ -1827,6 +1828,10 @@ export async function buildDataGridContextFilterCondition(options: DataGridConte
return result ?? undefined;
}
export async function buildDmlChangePreviewSql(options: DmlChangePreviewSqlOptions): Promise<DmlChangePreviewSqlResult> {
return invoke("build_dml_change_preview_sql", { options });
}
export async function buildDataGridColumnValueFilterCondition(options: DataGridColumnValueFilterConditionOptions): Promise<string | undefined> {
const result = await invoke<string | null>("build_data_grid_column_value_filter_condition", { options });
return result ?? undefined;
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { looksLikeDmlStatement } from "../dmlChangePreview";
describe("looksLikeDmlStatement", () => {
it("recognizes UPDATE / INSERT / DELETE statements", () => {
expect(looksLikeDmlStatement("UPDATE admin_sessions SET public_id = 123 WHERE id = 1")).toBe(true);
expect(looksLikeDmlStatement(" INSERT INTO t (a) VALUES (1)")).toBe(true);
expect(looksLikeDmlStatement("\nDELETE FROM t WHERE id = 2")).toBe(true);
});
it("ignores leading whitespace and preserves case-insensitivity", () => {
expect(looksLikeDmlStatement(" update t set a = 1")).toBe(true);
expect(looksLikeDmlStatement("Insert into t values (1)")).toBe(true);
});
it("rejects non-DML and unrelated statements", () => {
expect(looksLikeDmlStatement("SELECT * FROM t")).toBe(false);
expect(looksLikeDmlStatement("WITH x AS (SELECT 1) SELECT * FROM x")).toBe(false);
expect(looksLikeDmlStatement("")).toBe(false);
expect(looksLikeDmlStatement("CREATE TABLE t (a int)")).toBe(false);
});
});
@@ -0,0 +1,37 @@
/** 「预览变更」请求/响应的前端类型(对应后端 dml_preview_sql)。 */
export interface DmlChangePreviewSqlOptions {
/** 用户写的一条 DML 语句(通常是当前语句)。 */
sql: string;
/** 连接类型(决定解析方言 / 默认标识符引号)。 */
databaseType?: string;
/** 连接的标识符引号字符(如 `"`、`` ` ``、`[`)。 */
identifierQuote?: string;
/** 目标表的列(ordinal)。提供且为单表 UPDATE 时,新值列紧跟其原值列。 */
columns?: string[];
}
export interface DmlChangePreviewTableRef {
catalog?: string;
database?: string;
schema?: string;
table?: string;
}
export interface DmlChangePreviewSqlResult {
/** 生成的只读 SELECT:在结果网格展示受影响行 + 「新值」列。 */
sql: string;
/** 语句类型:update / insert / delete。 */
operation: string;
/** 是否有「新值」列(仅 UPDATE 有)。 */
hasNewValueColumns: boolean;
/** 语句目标表引用(供前端拉取列元数据以交错展示新值列)。 */
tables: DmlChangePreviewTableRef[];
}
/** 从 SQL 文本粗略识别是否为可预览的 DML(用于「预览变更」入口显隐)。 */
const DML_PREVIEW_RE = /^\s*(update|insert|delete)\b/i;
export function looksLikeDmlStatement(sql: string): boolean {
return DML_PREVIEW_RE.test(sql);
}