feat(editor): preview dml changes as readonly select
This commit is contained in:
1 parent
d59b86bd57
commit
333c3a3f44
25 files changed
+848
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1404,6 +1404,10 @@ export default withEnglishFallback({
|
||||
selectFirst: "リストテキストを選択してください",
|
||||
},
|
||||
connectionOnlyTarget: "接続レベル目標",
|
||||
previewChanges: "変更をプレビュー",
|
||||
previewChangesComment: "変更プレビュー(ドライラン):{operation} ステートメント",
|
||||
previewChangesFailed: "変更プレビューに失敗しました。",
|
||||
previewChangesNoStatement: "変更をプレビューするには、UPDATE / INSERT / DELETE ステートメントを選択するか、その上にカーソルを置いてください。",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
@@ -1286,6 +1286,10 @@ export default withEnglishFallback({
|
||||
currentCommand: "현재 명령",
|
||||
allCommands: "전체 명령",
|
||||
},
|
||||
previewChanges: "변경 사항 미리 보기",
|
||||
previewChangesComment: "변경 미리 보기(드라이 런): {operation} 문",
|
||||
previewChangesFailed: "변경 미리 보기 실패.",
|
||||
previewChangesNoStatement: "변경 사항을 미리 보려면 UPDATE / INSERT / DELETE 문을 선택하거나 커서를 해당 문에 놓으십시오.",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1264,6 +1264,10 @@ export default withEnglishFallback({
|
||||
JSON_ARRAY: "创建 JSON 数组",
|
||||
},
|
||||
},
|
||||
previewChanges: "预览变更",
|
||||
previewChangesComment: "变更预览(干跑):{operation} 语句",
|
||||
previewChangesFailed: "变更预览失败。",
|
||||
previewChangesNoStatement: "请选中或把光标放在一条 UPDATE / INSERT / DELETE 语句上以预览变更。",
|
||||
contextMenu: {
|
||||
executeSelection: "执行选中 SQL",
|
||||
executeCurrent: "执行 SQL",
|
||||
|
||||
@@ -1383,6 +1383,10 @@ export default withEnglishFallback({
|
||||
selectFirst: "請選擇清單文字",
|
||||
},
|
||||
connectionOnlyTarget: "連線級目標",
|
||||
previewChanges: "預覽變更",
|
||||
previewChangesComment: "變更預覽(乾跑):{operation} 語句",
|
||||
previewChangesFailed: "變更預覽失敗。",
|
||||
previewChangesNoStatement: "請選取或將游標放在一條 UPDATE / INSERT / DELETE 語句上以預覽變更。",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! 「预览变更」SQL 生成:把 SQL 编辑器里的一条 DML(UPDATE / INSERT / DELETE)
|
||||
//! 改写成一条只读 SELECT,用于在执行前干跑预览受影响的数据。
|
||||
//!
|
||||
//! 思路:不手工重建 sqlparser AST,而是复用 sqlparser 对表达式 / 表 / WHERE 的
|
||||
//! 方言正确渲染(`Display` round-trip),只在外层做字符串装配:
|
||||
//! UPDATE t SET a = 123 WHERE id = 1
|
||||
//! → SELECT *, 123 AS "a (new)" FROM t WHERE id = 1
|
||||
//! `SELECT *` 展开受影响行全部当前值,新值列(别名后缀 ` (new)`)追加在末尾;
|
||||
//! SET 表达式直接搬运(在行上下文中按旧值求值即得新值,`SET a = a + 1` 也能算对)。
|
||||
//! 注意:SET / VALUES 表达式会在预览 SELECT 中重新求值,带副作用的表达式
|
||||
//! (推进序列、volatile 函数)在预览中同样生效,非确定性函数的预览值可能与实际执行不同。
|
||||
//!
|
||||
//! 无法安全改写的语句返回 Err(错误信息为英文,前端直接以 toast 展示,仍可正常执行原语句)。
|
||||
|
||||
use sqlparser::ast::{AssignmentTarget, Expr, Ident, ObjectNamePart, SetExpr, Statement, TableWithJoins};
|
||||
use sqlparser::dialect::{
|
||||
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect,
|
||||
SparkSqlDialect,
|
||||
};
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DmlChangePreviewSqlOptions {
|
||||
/// 用户写的一条 DML 语句(通常是当前语句)。
|
||||
pub sql: String,
|
||||
/// 连接类型(决定解析方言 / 默认标识符引号)。
|
||||
pub database_type: Option<String>,
|
||||
/// 连接的标识符引号字符(如 `"`、`` ` ``、`[`),None 时按方言回退。
|
||||
pub identifier_quote: Option<String>,
|
||||
/// 目标表的列(按 ordinal 顺序)。提供且为单表 UPDATE 时,新值列紧跟其原值列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DmlChangePreviewTableRef {
|
||||
pub catalog: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
pub table: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DmlChangePreviewSqlResult {
|
||||
/// 生成的只读 SELECT。
|
||||
pub sql: String,
|
||||
/// 语句类型:update / insert / delete。
|
||||
pub operation: String,
|
||||
/// 是否有「新值」列(仅 UPDATE 有,INSERT 的列本身即新值)。
|
||||
pub has_new_value_columns: bool,
|
||||
/// 语句目标表引用(供前端拉取列元数据以交错展示新值列)。
|
||||
pub tables: Vec<DmlChangePreviewTableRef>,
|
||||
}
|
||||
|
||||
fn normalize_dialect(database_type: Option<&str>) -> &'static str {
|
||||
match database_type.unwrap_or("generic").to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" | "redshift" | "opengauss" | "gaussdb" | "highgo" | "uxdb" | "questdb" => "postgres",
|
||||
"mysql" | "mariadb" | "doris" | "starrocks" | "manticoresearch" | "oceanbase" | "tidb" => "mysql",
|
||||
"sqlite" => "sqlite",
|
||||
"sqlserver" | "mssql" => "sqlserver",
|
||||
"clickhouse" => "clickhouse",
|
||||
"duckdb" => "duckdb",
|
||||
"spark" | "sparksql" => "spark",
|
||||
_ => "generic",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_statements(sql: &str, dialect_key: &str) -> Result<Vec<Statement>, String> {
|
||||
match dialect_key {
|
||||
"postgres" => Parser::parse_sql(&PostgreSqlDialect {}, sql),
|
||||
"mysql" => Parser::parse_sql(&MySqlDialect {}, sql),
|
||||
"sqlite" => Parser::parse_sql(&SQLiteDialect {}, sql),
|
||||
"sqlserver" => Parser::parse_sql(&MsSqlDialect {}, sql),
|
||||
"clickhouse" => Parser::parse_sql(&ClickHouseDialect {}, sql),
|
||||
"duckdb" => Parser::parse_sql(&DuckDbDialect {}, sql),
|
||||
"spark" => Parser::parse_sql(&SparkSqlDialect {}, sql),
|
||||
_ => Parser::parse_sql(&GenericDialect {}, sql),
|
||||
}
|
||||
.map_err(|error| format!("Failed to parse the statement: {error}"))
|
||||
}
|
||||
|
||||
fn quote_identifier(name: &str, quote: Option<&str>, dialect_key: &str) -> String {
|
||||
if let Some(value) = quote {
|
||||
if value == "`" || value == "\"" || value == "'" {
|
||||
return format!("{value}{name}{value}");
|
||||
}
|
||||
if value == "[" {
|
||||
return format!("[{name}]");
|
||||
}
|
||||
}
|
||||
match dialect_key {
|
||||
"mysql" => format!("`{name}`"),
|
||||
_ => format!("\"{name}\""),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从赋值目标里取出列名(`t.a` → `a`)。
|
||||
fn assignment_target_column(target: &AssignmentTarget) -> Option<&Ident> {
|
||||
match target {
|
||||
AssignmentTarget::ColumnName(object_name) => object_name.0.last().and_then(ObjectNamePart::as_ident),
|
||||
AssignmentTarget::Tuple(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(reason: &str) -> String {
|
||||
format!("Preview is not supported: {reason}")
|
||||
}
|
||||
|
||||
/// UPDATE → SELECT <列交错展开> FROM … WHERE …
|
||||
///
|
||||
/// 提供目标表列清单且为单表时,按「原列, 原列 (new)」逐列交错展开,使新值列紧跟
|
||||
/// 其原值列;否则退化为 `SELECT *, <SET 表达式> AS "列 (new)", …`(追加在末尾)。
|
||||
fn build_update_preview(
|
||||
update: &sqlparser::ast::Update,
|
||||
dialect_key: &str,
|
||||
quote: Option<&str>,
|
||||
columns: Option<&[String]>,
|
||||
) -> Result<String, String> {
|
||||
// 收集 SET 新值表达式:同列重复赋值取最后一次,但保持首次出现的列序。
|
||||
#[derive(Clone)]
|
||||
struct SetItem {
|
||||
column: String,
|
||||
expr: String,
|
||||
}
|
||||
let mut set_items: Vec<SetItem> = Vec::new();
|
||||
let mut set_order: Vec<String> = Vec::new(); // 小写列名 → 顺序
|
||||
for assignment in &update.assignments {
|
||||
let Some(column) = assignment_target_column(&assignment.target) else {
|
||||
return Err(unsupported("UPDATE assignment target is not a single column"));
|
||||
};
|
||||
let column_name = column.value.clone();
|
||||
let key = column_name.to_lowercase();
|
||||
if let Some(position) = set_order.iter().position(|existing| *existing == key) {
|
||||
set_order.remove(position);
|
||||
set_items.remove(position);
|
||||
}
|
||||
set_order.push(key);
|
||||
set_items.push(SetItem { column: column_name, expr: assignment.value.to_string() });
|
||||
}
|
||||
|
||||
// Postgres `UPDATE … FROM …`:同一目标行可能匹配多行来源,真实 UPDATE 只更新一次,
|
||||
// 而改写出的 SELECT 会把该行显示多次(跨连接重复计数),直接拒绝预览。
|
||||
if let Some(from_kind) = &update.from {
|
||||
let extra_count = match from_kind {
|
||||
sqlparser::ast::UpdateTableFromKind::BeforeSet(list)
|
||||
| sqlparser::ast::UpdateTableFromKind::AfterSet(list) => list.len(),
|
||||
};
|
||||
if extra_count > 0 {
|
||||
return Err(unsupported("UPDATE ... FROM"));
|
||||
}
|
||||
}
|
||||
let from_sql = update.table.to_string();
|
||||
|
||||
let render_new_value = |item: &SetItem| -> String {
|
||||
format!("{} AS {}", item.expr, quote_identifier(&format!("{} (new)", item.column), quote, dialect_key))
|
||||
};
|
||||
let append_items: Vec<String> = set_items.iter().map(render_new_value).collect();
|
||||
|
||||
// 单表 + 提供了列清单 → 交错展开(新值列紧跟原值列)。
|
||||
let single_table = update.table.joins.is_empty() && update.from.is_none();
|
||||
let select_items = if single_table {
|
||||
match columns {
|
||||
Some(column_list) if !column_list.is_empty() => {
|
||||
let mut projection: Vec<String> = Vec::new();
|
||||
let mut placed: Vec<String> = Vec::new();
|
||||
for column in column_list {
|
||||
projection.push(quote_identifier(column, quote, dialect_key));
|
||||
let key = column.to_lowercase();
|
||||
if let Some(position) = set_order.iter().position(|existing| *existing == key) {
|
||||
projection.push(render_new_value(&set_items[position]));
|
||||
placed.push(key);
|
||||
}
|
||||
}
|
||||
// SET 列未在列清单中(大小写差异 / 虚拟列等)追加到末尾,避免遗漏。
|
||||
for (index, key) in set_order.iter().enumerate() {
|
||||
if !placed.contains(key) {
|
||||
projection.push(render_new_value(&set_items[index]));
|
||||
}
|
||||
}
|
||||
projection.join(", ")
|
||||
}
|
||||
_ => {
|
||||
if append_items.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
format!("*, {}", append_items.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if append_items.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
format!("*, {}", append_items.join(", "))
|
||||
};
|
||||
|
||||
let mut sql = format!("SELECT {select_items} FROM {from_sql}");
|
||||
if let Some(selection) = &update.selection {
|
||||
sql.push_str(&format!(" WHERE {selection}"));
|
||||
}
|
||||
if !update.order_by.is_empty() {
|
||||
let order_by = update.order_by.iter().map(|item| item.to_string()).collect::<Vec<_>>().join(", ");
|
||||
sql.push_str(&format!(" ORDER BY {order_by}"));
|
||||
}
|
||||
if let Some(limit) = &update.limit {
|
||||
sql.push_str(&format!(" LIMIT {limit}"));
|
||||
}
|
||||
Ok(sql)
|
||||
}
|
||||
|
||||
/// INSERT … VALUES → SELECT 值行 UNION ALL …(列名来自插入列,直观展示即将插入的行)。
|
||||
fn build_insert_preview(
|
||||
insert: &sqlparser::ast::Insert,
|
||||
dialect_key: &str,
|
||||
quote: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
// MySQL `INSERT INTO t SET a=1, b=2`
|
||||
if !insert.assignments.is_empty() {
|
||||
let items: Vec<String> = insert
|
||||
.assignments
|
||||
.iter()
|
||||
.map(|assignment| {
|
||||
let col = assignment_target_column(&assignment.target)
|
||||
.ok_or_else(|| unsupported("INSERT ... SET assignment target is not a single column"))?;
|
||||
Ok(format!("{} AS {}", assignment.value, quote_identifier(&col.value, quote, dialect_key)))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
if items.is_empty() {
|
||||
return Err(unsupported("INSERT ... SET has no assignments"));
|
||||
}
|
||||
return Ok(format!("SELECT {}", items.join(", ")));
|
||||
}
|
||||
|
||||
let Some(source) = &insert.source else {
|
||||
return Err(unsupported("INSERT has no source"));
|
||||
};
|
||||
|
||||
match source.body.as_ref() {
|
||||
SetExpr::Values(values) => {
|
||||
let column_names: Vec<String> = insert
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|column| {
|
||||
column.0.last().and_then(ObjectNamePart::as_ident).map(|ident| ident.value.clone())
|
||||
})
|
||||
.collect();
|
||||
if !column_names.is_empty() && values.rows.iter().any(|row| row.content.len() != column_names.len()) {
|
||||
return Err(unsupported("INSERT VALUES row length does not match the column list"));
|
||||
}
|
||||
let render_row = |row_exprs: &[Expr]| -> String {
|
||||
let cells: Vec<String> = row_exprs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, expr)| {
|
||||
let expr_text = expr.to_string();
|
||||
if column_names.is_empty() {
|
||||
expr_text
|
||||
} else {
|
||||
format!("{expr_text} AS {}", quote_identifier(&column_names[index], quote, dialect_key))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("SELECT {}", cells.join(", "))
|
||||
};
|
||||
let rows = values.rows.iter().collect::<Vec<_>>();
|
||||
let Some(first) = rows.first() else {
|
||||
return Err(unsupported("INSERT VALUES is empty"));
|
||||
};
|
||||
let mut sql = render_row(&first.content);
|
||||
for row in rows.iter().skip(1) {
|
||||
sql.push_str(" UNION ALL ");
|
||||
sql.push_str(&render_row(&row.content));
|
||||
}
|
||||
Ok(sql)
|
||||
}
|
||||
_ => {
|
||||
// INSERT … SELECT:预览 = 源查询(即将会插入的行)。
|
||||
Ok(source.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE → SELECT * FROM … WHERE …(标明将删除的行;无「新值」列)。
|
||||
fn build_delete_preview(
|
||||
delete: &sqlparser::ast::Delete,
|
||||
_dialect_key: &str,
|
||||
_quote: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let from_sql = match &delete.from {
|
||||
sqlparser::ast::FromTable::WithFromKeyword(tables) | sqlparser::ast::FromTable::WithoutKeyword(tables) => {
|
||||
tables.iter().map(|table: &TableWithJoins| table.to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
};
|
||||
if from_sql.is_empty() {
|
||||
return Err(unsupported("DELETE has no FROM"));
|
||||
}
|
||||
let mut sql = format!("SELECT * FROM {from_sql}");
|
||||
if let Some(using) = &delete.using {
|
||||
if !using.is_empty() {
|
||||
let using_sql = using.iter().map(|table| table.to_string()).collect::<Vec<_>>().join(", ");
|
||||
sql.push_str(&format!(", {using_sql}"));
|
||||
}
|
||||
}
|
||||
if let Some(selection) = &delete.selection {
|
||||
sql.push_str(&format!(" WHERE {selection}"));
|
||||
}
|
||||
if !delete.order_by.is_empty() {
|
||||
let order_by = delete.order_by.iter().map(|item| item.to_string()).collect::<Vec<_>>().join(", ");
|
||||
sql.push_str(&format!(" ORDER BY {order_by}"));
|
||||
}
|
||||
if let Some(limit) = &delete.limit {
|
||||
sql.push_str(&format!(" LIMIT {limit}"));
|
||||
}
|
||||
Ok(sql)
|
||||
}
|
||||
|
||||
pub fn build_dml_change_preview_sql(options: DmlChangePreviewSqlOptions) -> Result<DmlChangePreviewSqlResult, String> {
|
||||
let dialect_key = normalize_dialect(options.database_type.as_deref());
|
||||
let statements = parse_statements(&options.sql, dialect_key)?;
|
||||
let statement = match statements.len() {
|
||||
0 => return Err(unsupported("no statement to preview")),
|
||||
1 => &statements[0],
|
||||
_ => return Err(unsupported("only one statement can be previewed at a time")),
|
||||
};
|
||||
let quote = options.identifier_quote.as_deref();
|
||||
let columns = options.columns.as_deref();
|
||||
match statement {
|
||||
Statement::Update(update) => Ok(DmlChangePreviewSqlResult {
|
||||
sql: build_update_preview(update, dialect_key, quote, columns)?,
|
||||
operation: "update".to_string(),
|
||||
has_new_value_columns: true,
|
||||
tables: vec![update_primary_table_ref(update)],
|
||||
}),
|
||||
Statement::Insert(insert) => Ok(DmlChangePreviewSqlResult {
|
||||
sql: build_insert_preview(insert, dialect_key, quote)?,
|
||||
operation: "insert".to_string(),
|
||||
has_new_value_columns: true,
|
||||
tables: vec![insert_table_ref(insert)],
|
||||
}),
|
||||
Statement::Delete(delete) => Ok(DmlChangePreviewSqlResult {
|
||||
sql: build_delete_preview(delete, dialect_key, quote)?,
|
||||
operation: "delete".to_string(),
|
||||
has_new_value_columns: false,
|
||||
tables: vec![delete_table_ref(delete)],
|
||||
}),
|
||||
_ => Err(unsupported("only UPDATE / INSERT / DELETE statements can be previewed")),
|
||||
}
|
||||
}
|
||||
|
||||
fn object_name_table_ref(name: &sqlparser::ast::ObjectName) -> DmlChangePreviewTableRef {
|
||||
let names: Vec<String> =
|
||||
name.0.iter().filter_map(|part| part.as_ident().map(|ident| ident.value.clone())).collect();
|
||||
match names.len() {
|
||||
4 => DmlChangePreviewTableRef {
|
||||
catalog: Some(names[0].clone()),
|
||||
database: Some(names[1].clone()),
|
||||
schema: Some(names[2].clone()),
|
||||
table: Some(names[3].clone()),
|
||||
},
|
||||
3 => DmlChangePreviewTableRef {
|
||||
catalog: None,
|
||||
database: Some(names[0].clone()),
|
||||
schema: Some(names[1].clone()),
|
||||
table: Some(names[2].clone()),
|
||||
},
|
||||
2 => DmlChangePreviewTableRef {
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: Some(names[0].clone()),
|
||||
table: Some(names[1].clone()),
|
||||
},
|
||||
1 => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: names.into_iter().next() },
|
||||
_ => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn update_primary_table_ref(update: &sqlparser::ast::Update) -> DmlChangePreviewTableRef {
|
||||
match &update.table.relation {
|
||||
sqlparser::ast::TableFactor::Table { name, .. } => object_name_table_ref(name),
|
||||
_ => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_table_ref(insert: &sqlparser::ast::Insert) -> DmlChangePreviewTableRef {
|
||||
match &insert.table {
|
||||
sqlparser::ast::TableObject::TableName(name) => object_name_table_ref(name),
|
||||
_ => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_table_ref(delete: &sqlparser::ast::Delete) -> DmlChangePreviewTableRef {
|
||||
if let Some(first) = delete.tables.first() {
|
||||
return object_name_table_ref(first);
|
||||
}
|
||||
let tables = match &delete.from {
|
||||
sqlparser::ast::FromTable::WithFromKeyword(tables) | sqlparser::ast::FromTable::WithoutKeyword(tables) => {
|
||||
tables
|
||||
}
|
||||
};
|
||||
match tables.first().and_then(|table| match &table.relation {
|
||||
sqlparser::ast::TableFactor::Table { name, .. } => Some(name),
|
||||
_ => None,
|
||||
}) {
|
||||
Some(name) => object_name_table_ref(name),
|
||||
None => DmlChangePreviewTableRef { catalog: None, database: None, schema: None, table: None },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn preview(sql: &str, database_type: &str) -> Result<DmlChangePreviewSqlResult, String> {
|
||||
build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: sql.to_string(),
|
||||
database_type: Some(database_type.to_string()),
|
||||
identifier_quote: None,
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_with_columns(
|
||||
sql: &str,
|
||||
database_type: &str,
|
||||
columns: Vec<String>,
|
||||
) -> Result<DmlChangePreviewSqlResult, String> {
|
||||
build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: sql.to_string(),
|
||||
database_type: Some(database_type.to_string()),
|
||||
identifier_quote: None,
|
||||
columns: Some(columns),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_simple_single_table() {
|
||||
let result = preview("UPDATE admin_sessions SET public_id = 123 WHERE id = 1", "mysql").unwrap();
|
||||
assert_eq!(result.operation, "update");
|
||||
assert_eq!(result.sql, "SELECT *, 123 AS `public_id (new)` FROM admin_sessions WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_multiple_assignments_and_expression() {
|
||||
let result = preview("UPDATE t SET a = 1, b = a + 1", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS \"a (new)\", a + 1 AS \"b (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_deduplicates_same_column_last_wins() {
|
||||
let result = preview("UPDATE t SET x = 1, x = 2", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 2 AS \"x (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_alias_and_join() {
|
||||
let result = preview("UPDATE a JOIN b ON a.id = b.id SET a.x = b.x WHERE a.id = 1", "mysql").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, b.x AS `x (new)` FROM a JOIN b ON a.id = b.id WHERE a.id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_postgres_from_clause_rejected() {
|
||||
// `UPDATE … FROM …` 的目标行可能匹配多行来源,跨连接改写会重复计数,应拒绝预览。
|
||||
let error = preview("UPDATE t SET x = s.y FROM s WHERE t.id = s.id", "postgres").unwrap_err();
|
||||
assert_eq!(error, "Preview is not supported: UPDATE ... FROM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_where_missing() {
|
||||
let result = preview("UPDATE t SET x = 1", "sqlite").unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS \"x (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_values_with_columns() {
|
||||
let result = preview("INSERT INTO t (a, b) VALUES (1, 'x'), (2, 'y')", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT 1 AS \"a\", 'x' AS \"b\" UNION ALL SELECT 2 AS \"a\", 'y' AS \"b\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_select_source() {
|
||||
let result = preview("INSERT INTO t SELECT id, name FROM src WHERE active", "postgres").unwrap();
|
||||
assert_eq!(result.sql, "SELECT id, name FROM src WHERE active");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_set_mysql() {
|
||||
let result = preview("INSERT INTO t SET a = 1, b = 'x'", "mysql").unwrap();
|
||||
assert_eq!(result.sql, "SELECT 1 AS `a`, 'x' AS `b`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_simple() {
|
||||
let result = preview("DELETE FROM t WHERE id = 1", "postgres").unwrap();
|
||||
assert_eq!(result.operation, "delete");
|
||||
assert_eq!(result.sql, "SELECT * FROM t WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_mysql_multi_table() {
|
||||
let result = preview("DELETE d FROM d JOIN logs l ON d.id = l.id WHERE l.ts < '2020-01-01'", "mysql").unwrap();
|
||||
assert_eq!(result.sql, "SELECT * FROM d JOIN logs l ON d.id = l.id WHERE l.ts < '2020-01-01'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_multiple_statements() {
|
||||
assert!(preview("UPDATE t SET a = 1; UPDATE t2 SET b = 2", "postgres").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_dml() {
|
||||
assert!(preview("SELECT * FROM t", "postgres").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_identifier_quote() {
|
||||
let result = build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: "UPDATE t SET a = 1 WHERE id = 1".to_string(),
|
||||
database_type: Some("postgres".to_string()),
|
||||
identifier_quote: Some("\"".to_string()),
|
||||
columns: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS \"a (new)\" FROM t WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sqlserver_bracket_quote() {
|
||||
let result = build_dml_change_preview_sql(DmlChangePreviewSqlOptions {
|
||||
sql: "UPDATE dbo.t SET a = 1 WHERE id = 1".to_string(),
|
||||
database_type: Some("sqlserver".to_string()),
|
||||
identifier_quote: Some("[".to_string()),
|
||||
columns: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, 1 AS [a (new)] FROM dbo.t WHERE id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interleaves_new_value_after_its_original_column() {
|
||||
let result = preview_with_columns(
|
||||
"UPDATE admin_sessions SET public_id = 123 WHERE id = 1",
|
||||
"mysql",
|
||||
vec!["id".to_string(), "public_id".to_string(), "session".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.sql,
|
||||
"SELECT `id`, `public_id`, 123 AS `public_id (new)`, `session` FROM admin_sessions WHERE id = 1"
|
||||
);
|
||||
assert_eq!(result.tables.len(), 1);
|
||||
assert_eq!(result.tables[0].table.as_deref(), Some("admin_sessions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interleave_multiple_assignments_keeps_order() {
|
||||
let result = preview_with_columns(
|
||||
"UPDATE t SET b = 2, a = 1",
|
||||
"postgres",
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT \"a\", 1 AS \"a (new)\", \"b\", 2 AS \"b (new)\", \"c\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interleave_missing_set_column_appends_at_end() {
|
||||
let result =
|
||||
preview_with_columns("UPDATE t SET z = 9", "postgres", vec!["a".to_string(), "b".to_string()]).unwrap();
|
||||
assert_eq!(result.sql, "SELECT \"a\", \"b\", 9 AS \"z (new)\" FROM t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_multi_table_ignores_columns_for_append_mode() {
|
||||
let result = preview_with_columns(
|
||||
"UPDATE a JOIN b ON a.id = b.id SET a.x = b.x WHERE a.id = 1",
|
||||
"mysql",
|
||||
vec!["a_x".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.sql, "SELECT *, b.x AS `x (new)` FROM a JOIN b ON a.id = b.id WHERE a.id = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_refs_extracted_for_dml() {
|
||||
let update = preview("UPDATE db.public.t SET a = 1", "postgres").unwrap();
|
||||
assert_eq!(update.tables.len(), 1);
|
||||
assert_eq!(update.tables[0].database.as_deref(), Some("db"));
|
||||
assert_eq!(update.tables[0].schema.as_deref(), Some("public"));
|
||||
assert_eq!(update.tables[0].table.as_deref(), Some("t"));
|
||||
|
||||
let insert = preview("INSERT INTO orders (id) VALUES (1)", "postgres").unwrap();
|
||||
assert_eq!(insert.tables[0].table.as_deref(), Some("orders"));
|
||||
|
||||
let delete = preview("DELETE FROM t WHERE id = 1", "postgres").unwrap();
|
||||
assert_eq!(delete.tables[0].table.as_deref(), Some("t"));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub mod database_search_sql;
|
||||
pub mod db;
|
||||
pub mod db_admin_sql;
|
||||
pub mod dml_binding;
|
||||
pub mod dml_preview_sql;
|
||||
pub mod docs;
|
||||
pub mod document_ops;
|
||||
pub mod driver_runtime;
|
||||
|
||||
@@ -593,6 +593,7 @@ async fn main() {
|
||||
"/query/build-data-grid-copy-insert-statement",
|
||||
post(routes::query::build_data_grid_copy_insert_statement),
|
||||
)
|
||||
.route("/query/build-dml-change-preview-sql", post(routes::query::build_dml_change_preview_sql))
|
||||
.route(
|
||||
"/query/build-data-grid-context-filter-condition",
|
||||
post(routes::query::build_data_grid_context_filter_condition),
|
||||
|
||||
@@ -322,6 +322,12 @@ pub struct BuildDataGridCopyInsertStatementRequest {
|
||||
pub options: dbx_core::data_grid_sql::DataGridCopyInsertStatementOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDmlChangePreviewSqlRequest {
|
||||
pub options: dbx_core::dml_preview_sql::DmlChangePreviewSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDataGridContextFilterConditionRequest {
|
||||
@@ -1068,6 +1074,14 @@ pub async fn build_data_grid_copy_insert_statement(
|
||||
Json(dbx_core::data_grid_sql::build_data_grid_copy_insert_statement(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_dml_change_preview_sql(
|
||||
Json(req): Json<BuildDmlChangePreviewSqlRequest>,
|
||||
) -> Result<Json<dbx_core::dml_preview_sql::DmlChangePreviewSqlResult>, (axum::http::StatusCode, Json<String>)> {
|
||||
dbx_core::dml_preview_sql::build_dml_change_preview_sql(req.options)
|
||||
.map(Json)
|
||||
.map_err(|message| (axum::http::StatusCode::BAD_REQUEST, Json(message)))
|
||||
}
|
||||
|
||||
pub async fn build_data_grid_context_filter_condition(
|
||||
Json(req): Json<BuildDataGridContextFilterConditionRequest>,
|
||||
) -> Json<Option<String>> {
|
||||
|
||||
@@ -56,6 +56,16 @@ description: 使用 DBX 编写、理解、参数化、执行、分析和复用 S
|
||||
|
||||
<Callout type="warn">查询取消取决于驱动和数据库能力。点击停止后,服务端可能仍需要时间中断正在执行的语句。</Callout>
|
||||
|
||||
### 执行前预览变更
|
||||
|
||||
对 `UPDATE` / `INSERT` / `DELETE` 语句,编辑区工具栏会出现**「预览变更」**按钮(右键菜单里也可用)。DBX 会把该语句改写成一条等价的只读 `SELECT`,在新结果标签中展示影响到的数据。预览会重新求值被写入的表达式,带副作用的表达式(推进序列、volatile 函数)在预览中同样生效,非确定性函数(`uuid()`、`rand()`、`now()`)显示的值可能与实际执行不同:
|
||||
|
||||
- `UPDATE`:网格展示受影响行 + 每个被赋值列一列 **`列名 (new)`**(表达式按行内旧值求值,`SET a = a + 1` 也能算出真实新值);
|
||||
- `INSERT`:网格展示即将插入的行(列名来自目标列,`VALUES` 展开);
|
||||
- `DELETE`:网格展示将被删除的行。
|
||||
|
||||
无法安全改写的语句会提示 **Preview is not supported**,但仍可正常执行原语句。
|
||||
|
||||
## 执行范围与目标选择器
|
||||
|
||||
| 范围 | 如何确定 | 适合场景 |
|
||||
|
||||
@@ -56,6 +56,16 @@ For multi-statement execution, the result area shows the current statement, tota
|
||||
|
||||
<Callout type="warn">Cancellation depends on the driver and database. After Stop is clicked, the server may still need time to interrupt the current statement.</Callout>
|
||||
|
||||
### Preview DML changes before executing
|
||||
|
||||
For an `UPDATE`, `INSERT`, or `DELETE` statement, the toolbar shows a **Preview changes** button (also available in the editor context menu). DBX rewrites the statement into an equivalent read-only `SELECT` and opens it in a new result tab. The preview re-evaluates the expressions being written, so expressions with side effects (advancing a sequence, volatile functions) still take effect during the preview, and values shown for non-deterministic functions (`uuid()`, `rand()`, `now()`) may differ from the actual execution:
|
||||
|
||||
- `UPDATE` — the grid shows the affected rows plus one **`column (new)`** column per assigned column (expressions are evaluated, so `SET a = a + 1` shows the actual new value);
|
||||
- `INSERT` — the grid shows the rows that would be inserted (column names from the target columns, `VALUES` expanded);
|
||||
- `DELETE` — the grid shows the rows that would be removed.
|
||||
|
||||
Statements DBX cannot rewrite safely (for example some dialect-specific forms, or `UPDATE ... FROM`) are reported as **Preview is not supported** and can still be executed normally.
|
||||
|
||||
## Execution Scope and Target Picker
|
||||
|
||||
| Scope | How it is selected | Best for |
|
||||
|
||||
@@ -829,6 +829,13 @@ pub fn build_data_grid_copy_insert_statement(
|
||||
Ok(dbx_core::data_grid_sql::build_data_grid_copy_insert_statement(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_dml_change_preview_sql(
|
||||
options: dbx_core::dml_preview_sql::DmlChangePreviewSqlOptions,
|
||||
) -> Result<dbx_core::dml_preview_sql::DmlChangePreviewSqlResult, String> {
|
||||
dbx_core::dml_preview_sql::build_dml_change_preview_sql(options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_data_grid_context_filter_condition(
|
||||
options: dbx_core::data_grid_sql::DataGridContextFilterConditionOptions,
|
||||
|
||||
@@ -1948,6 +1948,7 @@ pub fn run() {
|
||||
commands::query::extract_data_grid_selection,
|
||||
commands::query::build_data_grid_copy_update_statements,
|
||||
commands::query::build_data_grid_copy_insert_statement,
|
||||
commands::query::build_dml_change_preview_sql,
|
||||
commands::query::build_data_grid_context_filter_condition,
|
||||
commands::query::build_data_grid_column_value_filter_condition,
|
||||
commands::query::build_data_grid_column_values_filter_condition,
|
||||
|
||||
Reference in new issue
Block a user