mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 08:23:34 +00:00
feat(history): track database activity
This commit is contained in:
+17
-8
@@ -40,6 +40,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { HistoryEntry } from "@/lib/tauri";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -93,6 +94,21 @@ const activeConnection = computed(() => {
|
||||
return tab ? connectionStore.getConfig(tab.connectionId) : undefined;
|
||||
});
|
||||
|
||||
function restoreHistorySql(sql: string, entry: HistoryEntry) {
|
||||
const tab = activeTab.value;
|
||||
if (tab?.mode === "query") {
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = entry.connection_id || tab?.connectionId || connectionStore.connections[0]?.id;
|
||||
if (!connectionId) return;
|
||||
const config = connectionStore.getConfig(connectionId);
|
||||
const database = entry.database || tab?.database || (config ? resolveDefaultDatabase(config, []) : "");
|
||||
const tabId = queryStore.createTab(connectionId, database || "", t("tabs.sql"));
|
||||
queryStore.updateSql(tabId, sql);
|
||||
}
|
||||
|
||||
const executableSql = computed(() => {
|
||||
const tab = activeTab.value;
|
||||
return tab
|
||||
@@ -634,14 +650,7 @@ onUnmounted(() => {
|
||||
:style="{ width: historyWidth + 'px' }"
|
||||
>
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
|
||||
<QueryHistory
|
||||
@restore="
|
||||
(sql: string) => {
|
||||
if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, sql);
|
||||
}
|
||||
"
|
||||
@close="showHistory = false"
|
||||
/>
|
||||
<QueryHistory @restore="restoreHistorySql" @close="showHistory = false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,39 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, Trash2, Clock, X } from "lucide-vue-next";
|
||||
import { Clock, Copy, Database, RotateCcw, Search, Trash2, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
|
||||
import type { HistoryEntry } from "@/lib/api";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const store = useHistoryStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
restore: [sql: string];
|
||||
restore: [sql: string, entry: HistoryEntry];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed";
|
||||
|
||||
const searchText = ref("");
|
||||
const activeFilter = ref<HistoryFilter>("all");
|
||||
const selectedEntry = ref<HistoryEntry | null>(null);
|
||||
const isRollingBack = ref(false);
|
||||
|
||||
const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"];
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!searchText.value) return store.entries;
|
||||
const q = searchText.value.toLowerCase();
|
||||
return store.entries.filter(
|
||||
(e) =>
|
||||
e.sql.toLowerCase().includes(q) ||
|
||||
e.connection_name.toLowerCase().includes(q) ||
|
||||
e.database.toLowerCase().includes(q),
|
||||
);
|
||||
return store.entries.filter((entry) => {
|
||||
if (activeFilter.value === "failed" && entry.success) return false;
|
||||
if (activeFilter.value !== "all" && activeFilter.value !== "failed" && activityKind(entry) !== activeFilter.value) {
|
||||
return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
return [entry.sql, entry.connection_name, entry.database, entry.operation, entry.target]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
|
||||
function restore(sql: string) {
|
||||
emit("restore", sql);
|
||||
function activityKind(entry: HistoryEntry) {
|
||||
return entry.activity_kind || "query";
|
||||
}
|
||||
|
||||
function copySql(sql: string) {
|
||||
navigator.clipboard.writeText(sql);
|
||||
function restore(entry: HistoryEntry) {
|
||||
emit("restore", entry.sql, entry);
|
||||
selectedEntry.value = null;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast(t("grid.copied"));
|
||||
}
|
||||
|
||||
function confirmDeleteEntry(id: string) {
|
||||
@@ -54,11 +75,83 @@ function formatTime(iso: string): string {
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatFullTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
function truncateSql(sql: string): string {
|
||||
const line = sql.replace(/\s+/g, " ").trim();
|
||||
return line.length > 120 ? line.slice(0, 120) + "..." : line;
|
||||
}
|
||||
|
||||
function entryTitle(entry: HistoryEntry) {
|
||||
return entry.target || entry.operation || truncateSql(entry.sql);
|
||||
}
|
||||
|
||||
function entrySubtitle(entry: HistoryEntry) {
|
||||
if (activityKind(entry) === "query") return truncateSql(entry.sql);
|
||||
return truncateSql(entry.sql || entry.target || entry.operation || "");
|
||||
}
|
||||
|
||||
function filterLabel(filter: HistoryFilter) {
|
||||
return t(`history.filters.${filter}`);
|
||||
}
|
||||
|
||||
function kindLabel(entry: HistoryEntry) {
|
||||
return t(`history.kinds.${activityKind(entry)}`);
|
||||
}
|
||||
|
||||
function kindShortLabel(entry: HistoryEntry) {
|
||||
return t(`history.kindShort.${activityKind(entry)}`);
|
||||
}
|
||||
|
||||
function detailsRows(entry: HistoryEntry) {
|
||||
const rows = [
|
||||
[t("history.detail.kind"), kindLabel(entry)],
|
||||
[t("history.detail.operation"), entry.operation || "-"],
|
||||
[t("history.detail.connection"), entry.connection_name || "-"],
|
||||
[t("history.detail.database"), entry.database || "-"],
|
||||
[t("history.detail.target"), entry.target || "-"],
|
||||
[t("history.detail.time"), formatFullTime(entry.executed_at)],
|
||||
[t("history.detail.duration"), `${entry.execution_time_ms}ms`],
|
||||
[t("history.detail.affectedRows"), entry.affected_rows ?? "-"],
|
||||
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
|
||||
];
|
||||
if (entry.error) rows.push([t("history.detail.error"), entry.error]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function rollback(entry: HistoryEntry) {
|
||||
if (!entry.connection_id || !entry.database || !entry.rollback_sql || isRollingBack.value) return;
|
||||
if (!window.confirm(t("history.rollbackConfirm"))) return;
|
||||
|
||||
isRollingBack.value = true;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await api.executeScript(entry.connection_id, entry.database, entry.rollback_sql);
|
||||
await store.add({
|
||||
connection_id: entry.connection_id,
|
||||
connection_name: entry.connection_name,
|
||||
database: entry.database,
|
||||
sql: entry.rollback_sql,
|
||||
execution_time_ms: Date.now() - start,
|
||||
success: true,
|
||||
activity_kind: "data_change",
|
||||
operation: "ROLLBACK",
|
||||
target: entry.target,
|
||||
affected_rows: result.affected_rows,
|
||||
details_json: JSON.stringify({ rollback_of: entry.id }),
|
||||
});
|
||||
toast(t("history.rollbackSuccess"));
|
||||
selectedEntry.value = null;
|
||||
} catch (e: any) {
|
||||
toast(t("history.rollbackFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
isRollingBack.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => store.load());
|
||||
</script>
|
||||
|
||||
@@ -76,16 +169,30 @@ onMounted(() => store.load());
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('history.search')"
|
||||
/>
|
||||
<div class="border-b shrink-0">
|
||||
<div class="flex items-center gap-1 px-2 py-1">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('history.search')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1 overflow-x-auto px-2 pb-2">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter"
|
||||
type="button"
|
||||
class="h-6 shrink-0 rounded border px-2 text-xs"
|
||||
:class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'"
|
||||
@click="activeFilter = filter"
|
||||
>
|
||||
{{ filterLabel(filter) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@@ -93,24 +200,36 @@ onMounted(() => store.load());
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
class="px-3 py-2 border-b border-border/50 cursor-pointer hover:bg-accent/50 text-xs"
|
||||
@click="restore(entry.sql)"
|
||||
@click="selectedEntry = entry"
|
||||
>
|
||||
<div class="flex items-center gap-1 mb-0.5">
|
||||
<span class="font-medium truncate">{{ entry.connection_name }}</span>
|
||||
<span v-if="entry.database" class="text-muted-foreground">/ {{ entry.database }}</span>
|
||||
<span
|
||||
class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground"
|
||||
>
|
||||
{{ kindShortLabel(entry) }}
|
||||
</span>
|
||||
<span class="font-medium truncate">{{ entryTitle(entry) }}</span>
|
||||
<span class="ml-auto text-muted-foreground shrink-0">{{ formatTime(entry.executed_at) }}</span>
|
||||
</div>
|
||||
<div class="font-mono text-muted-foreground truncate">{{ truncateSql(entry.sql) }}</div>
|
||||
<div class="font-mono text-muted-foreground truncate">{{ entrySubtitle(entry) }}</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Database class="h-3 w-3" />
|
||||
{{ entry.connection_name }}<template v-if="entry.database"> / {{ entry.database }}</template>
|
||||
</span>
|
||||
<span :class="entry.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="w-40">
|
||||
<ContextMenuItem @click="restore(entry.sql)">{{ t("history.restore") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySql(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
|
||||
<ContextMenuContent class="w-44">
|
||||
<ContextMenuItem @click="selectedEntry = entry">{{ t("history.viewDetails") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="restore(entry)">{{ t("history.restore") }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyText(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
|
||||
<ContextMenuItem v-if="entry.rollback_sql" @click="rollback(entry)">{{
|
||||
t("history.rollback")
|
||||
}}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{
|
||||
t("history.delete")
|
||||
}}</ContextMenuItem>
|
||||
@@ -121,5 +240,56 @@ onMounted(() => store.load());
|
||||
{{ t("history.empty") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog :open="!!selectedEntry" @update:open="(value) => !value && (selectedEntry = null)">
|
||||
<DialogContent class="sm:max-w-2xl duration-75" @interact-outside="selectedEntry = null">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ selectedEntry ? entryTitle(selectedEntry) : t("history.viewDetails") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div v-if="selectedEntry" class="space-y-4">
|
||||
<div class="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<template v-for="[label, value] in detailsRows(selectedEntry)" :key="label">
|
||||
<div class="text-muted-foreground">{{ label }}</div>
|
||||
<div class="min-w-0 break-words">{{ value }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">SQL</div>
|
||||
<Button variant="ghost" size="sm" class="h-7" @click="copyText(selectedEntry.sql)">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{{ t("history.copy") }}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-auto rounded border bg-muted/30 p-3 text-xs"
|
||||
><code>{{ selectedEntry.sql }}</code></pre>
|
||||
</div>
|
||||
<div v-if="selectedEntry.rollback_sql">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">{{ t("history.rollbackSql") }}</div>
|
||||
<Button variant="ghost" size="sm" class="h-7" @click="copyText(selectedEntry.rollback_sql || '')">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{{ t("history.copy") }}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-40 overflow-auto rounded border bg-muted/30 p-3 text-xs"
|
||||
><code>{{ selectedEntry.rollback_sql }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
|
||||
<Button
|
||||
v-if="selectedEntry?.rollback_sql"
|
||||
:disabled="isRollingBack || !selectedEntry.connection_id"
|
||||
@click="rollback(selectedEntry)"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
{{ isRollingBack ? t("common.loading") : t("history.rollback") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
type CellSelectionRange,
|
||||
} from "@/lib/gridSelection";
|
||||
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import { buildDataGridRollbackStatements, buildDataGridSaveStatements, formatGridSqlLiteral } from "@/lib/dataGridSql";
|
||||
import { formatMarkdownTable } from "@/lib/markdownTable";
|
||||
import { buildXlsxWorkbook } from "@/lib/xlsxExport";
|
||||
import {
|
||||
@@ -84,9 +84,13 @@ import {
|
||||
} from "@/lib/gridRowStatus";
|
||||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const historyStore = useHistoryStore();
|
||||
|
||||
const props = defineProps<{
|
||||
result: QueryResult;
|
||||
@@ -1539,28 +1543,81 @@ function deleteSelectedRow() {
|
||||
requestDeleteRow(contextCell.value.rowId);
|
||||
}
|
||||
|
||||
function generateSaveStatements(): string[] {
|
||||
if (!props.tableMeta) return [];
|
||||
return buildDataGridSaveStatements({
|
||||
function saveStatementOptions() {
|
||||
if (!props.tableMeta) return null;
|
||||
return {
|
||||
databaseType: props.databaseType,
|
||||
tableMeta: props.tableMeta,
|
||||
columns: props.result.columns,
|
||||
rows: props.result.rows,
|
||||
dirtyRows: [...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]),
|
||||
dirtyRows: [...dirtyRows.value.entries()].map(
|
||||
([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>],
|
||||
),
|
||||
deletedRows: [...deletedRows.value],
|
||||
newRows: newRows.value,
|
||||
};
|
||||
}
|
||||
|
||||
function tableHistoryTarget() {
|
||||
if (!props.tableMeta) return "";
|
||||
return [props.tableMeta.schema, props.tableMeta.tableName].filter(Boolean).join(".");
|
||||
}
|
||||
|
||||
function dataChangeOperation() {
|
||||
const operations = [
|
||||
newRows.value.length > 0 ? "INSERT" : "",
|
||||
dirtyRows.value.size > 0 ? "UPDATE" : "",
|
||||
deletedRows.value.size > 0 ? "DELETE" : "",
|
||||
].filter(Boolean);
|
||||
return operations.length === 1 ? operations[0] : "DATA CHANGE";
|
||||
}
|
||||
|
||||
async function recordDataGridHistory(
|
||||
statements: string[],
|
||||
rollbackStatements: string[],
|
||||
elapsed: number,
|
||||
result?: { affected_rows?: number },
|
||||
) {
|
||||
if (!props.connectionId || !props.database || !props.tableMeta) return;
|
||||
const connName = connectionStore.getConfig(props.connectionId)?.name || "";
|
||||
const details = {
|
||||
schema: props.tableMeta.schema,
|
||||
table: props.tableMeta.tableName,
|
||||
inserted_rows: newRows.value.length,
|
||||
updated_rows: dirtyRows.value.size,
|
||||
deleted_rows: deletedRows.value.size,
|
||||
statements,
|
||||
rollback_statements: rollbackStatements,
|
||||
};
|
||||
await historyStore.add({
|
||||
connection_id: props.connectionId,
|
||||
connection_name: connName,
|
||||
database: props.database,
|
||||
sql: statements.join("\n"),
|
||||
execution_time_ms: elapsed,
|
||||
success: true,
|
||||
activity_kind: "data_change",
|
||||
operation: dataChangeOperation(),
|
||||
target: tableHistoryTarget(),
|
||||
affected_rows: result?.affected_rows ?? statements.length,
|
||||
rollback_sql: rollbackStatements.length ? rollbackStatements.join("\n") : undefined,
|
||||
details_json: JSON.stringify(details),
|
||||
});
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
const stmts = generateSaveStatements();
|
||||
const options = saveStatementOptions();
|
||||
const stmts = options ? buildDataGridSaveStatements(options) : [];
|
||||
if (stmts.length === 0) return;
|
||||
const rollbackStmts = options ? buildDataGridRollbackStatements(options) : [];
|
||||
saveError.value = "";
|
||||
isSaving.value = true;
|
||||
const start = Date.now();
|
||||
let result: { affected_rows?: number } | undefined;
|
||||
|
||||
if (useTransaction.value && props.connectionId && props.database) {
|
||||
try {
|
||||
await api.executeInTransaction(props.connectionId, props.database, stmts, props.tableMeta?.schema);
|
||||
result = await api.executeInTransaction(props.connectionId, props.database, stmts, props.tableMeta?.schema);
|
||||
} catch (e: any) {
|
||||
saveError.value = String(e.message || e);
|
||||
isSaving.value = false;
|
||||
@@ -1568,7 +1625,7 @@ async function saveChanges() {
|
||||
}
|
||||
} else if (props.connectionId && props.database) {
|
||||
try {
|
||||
await api.executeBatch(props.connectionId, props.database, stmts);
|
||||
result = await api.executeBatch(props.connectionId, props.database, stmts);
|
||||
} catch (e: any) {
|
||||
saveError.value = String(e.message || e);
|
||||
isSaving.value = false;
|
||||
@@ -1585,6 +1642,11 @@ async function saveChanges() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, result);
|
||||
} catch (e) {
|
||||
console.warn("[DBX] failed to record data grid history", e);
|
||||
}
|
||||
dirtyRows.value.clear();
|
||||
newRows.value = [];
|
||||
deletedRows.value.clear();
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useToast } from "@/composables/useToast";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
|
||||
const READ_RE = /^\s*(SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i;
|
||||
const WRITE_RE = /^\s*(INSERT|UPDATE|DELETE|MERGE|REPLACE|TRUNCATE)\b/i;
|
||||
const SCHEMA_RE = /^\s*(CREATE|ALTER|DROP|RENAME)\b/i;
|
||||
|
||||
export function stripSqlComments(sql: string): string {
|
||||
return sql
|
||||
@@ -20,6 +23,27 @@ export function isDangerousSql(sql: string): boolean {
|
||||
return cleaned.split(";").some((stmt) => DANGER_RE.test(stmt));
|
||||
}
|
||||
|
||||
function primarySqlOperation(sql: string): string {
|
||||
const cleaned = stripSqlComments(sql);
|
||||
const statement = cleaned
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find(Boolean);
|
||||
return statement?.match(/^([a-z]+)/i)?.[1]?.toUpperCase() || "SQL";
|
||||
}
|
||||
|
||||
function activityKindForSql(sql: string): "query" | "data_change" | "schema_change" {
|
||||
const cleaned = stripSqlComments(sql);
|
||||
const statements = cleaned
|
||||
.split(";")
|
||||
.map((stmt) => stmt.trim())
|
||||
.filter(Boolean);
|
||||
if (statements.some((stmt) => SCHEMA_RE.test(stmt))) return "schema_change";
|
||||
if (statements.some((stmt) => WRITE_RE.test(stmt))) return "data_change";
|
||||
if (statements.every((stmt) => READ_RE.test(stmt))) return "query";
|
||||
return "query";
|
||||
}
|
||||
|
||||
export function useSqlExecution(deps: {
|
||||
activeTab: ComputedRef<QueryTab | undefined>;
|
||||
activeConnection: ComputedRef<ConnectionConfig | undefined>;
|
||||
@@ -59,12 +83,16 @@ export function useSqlExecution(deps: {
|
||||
const elapsed = Date.now() - start;
|
||||
const success = !tab.result?.columns.includes("Error");
|
||||
historyStore.add({
|
||||
connection_id: tab.connectionId,
|
||||
connection_name: connName,
|
||||
database: tab.database,
|
||||
sql,
|
||||
execution_time_ms: elapsed,
|
||||
success,
|
||||
error: success ? undefined : String(tab.result?.rows?.[0]?.[0] ?? ""),
|
||||
activity_kind: activityKindForSql(sql),
|
||||
operation: primarySqlOperation(sql),
|
||||
affected_rows: success ? tab.result?.affected_rows : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+42
-2
@@ -641,11 +641,51 @@ export default {
|
||||
search: "Search history...",
|
||||
empty: "No history yet",
|
||||
failed: "Failed",
|
||||
success: "Succeeded",
|
||||
restore: "Restore to editor",
|
||||
copy: "Copy SQL",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete this query history entry?",
|
||||
confirmClear: "Clear all query history?",
|
||||
viewDetails: "View details",
|
||||
rollback: "Rollback",
|
||||
rollbackSql: "Rollback SQL",
|
||||
rollbackConfirm: "Execute the rollback SQL generated for this history entry?",
|
||||
rollbackSuccess: "Rollback executed",
|
||||
rollbackFailed: "Rollback failed: {message}",
|
||||
confirmDelete: "Delete this history entry?",
|
||||
confirmClear: "Clear all history?",
|
||||
filters: {
|
||||
all: "All",
|
||||
query: "Queries",
|
||||
data_change: "Data changes",
|
||||
schema_change: "Schema changes",
|
||||
failed: "Failed",
|
||||
},
|
||||
kinds: {
|
||||
query: "Query",
|
||||
data_change: "Data change",
|
||||
schema_change: "Schema change",
|
||||
import: "Import",
|
||||
transfer: "Transfer",
|
||||
},
|
||||
kindShort: {
|
||||
query: "SQL",
|
||||
data_change: "Data",
|
||||
schema_change: "DDL",
|
||||
import: "Import",
|
||||
transfer: "Move",
|
||||
},
|
||||
detail: {
|
||||
kind: "Type",
|
||||
operation: "Operation",
|
||||
connection: "Connection",
|
||||
database: "Database",
|
||||
target: "Target",
|
||||
time: "Time",
|
||||
duration: "Duration",
|
||||
affectedRows: "Affected rows",
|
||||
status: "Status",
|
||||
error: "Error",
|
||||
},
|
||||
},
|
||||
dangerDialog: {
|
||||
title: "Dangerous Operation",
|
||||
|
||||
@@ -625,15 +625,55 @@ export default {
|
||||
edit: "编辑",
|
||||
},
|
||||
history: {
|
||||
title: "查询历史",
|
||||
title: "历史",
|
||||
search: "搜索历史...",
|
||||
empty: "暂无历史记录",
|
||||
failed: "失败",
|
||||
success: "成功",
|
||||
restore: "恢复到编辑器",
|
||||
copy: "复制 SQL",
|
||||
delete: "删除",
|
||||
confirmDelete: "确认删除这条查询历史吗?",
|
||||
confirmClear: "确认清空所有查询历史吗?",
|
||||
viewDetails: "查看详情",
|
||||
rollback: "回滚",
|
||||
rollbackSql: "回滚 SQL",
|
||||
rollbackConfirm: "将执行这条历史记录生成的回滚 SQL,确认继续吗?",
|
||||
rollbackSuccess: "回滚已执行",
|
||||
rollbackFailed: "回滚失败:{message}",
|
||||
confirmDelete: "确认删除这条历史记录吗?",
|
||||
confirmClear: "确认清空所有历史记录吗?",
|
||||
filters: {
|
||||
all: "全部",
|
||||
query: "查询",
|
||||
data_change: "数据变更",
|
||||
schema_change: "结构变更",
|
||||
failed: "失败",
|
||||
},
|
||||
kinds: {
|
||||
query: "查询",
|
||||
data_change: "数据变更",
|
||||
schema_change: "结构变更",
|
||||
import: "导入",
|
||||
transfer: "传输",
|
||||
},
|
||||
kindShort: {
|
||||
query: "查询",
|
||||
data_change: "变更",
|
||||
schema_change: "结构",
|
||||
import: "导入",
|
||||
transfer: "传输",
|
||||
},
|
||||
detail: {
|
||||
kind: "类型",
|
||||
operation: "操作",
|
||||
connection: "连接",
|
||||
database: "数据库",
|
||||
target: "对象",
|
||||
time: "时间",
|
||||
duration: "耗时",
|
||||
affectedRows: "影响行数",
|
||||
status: "状态",
|
||||
error: "错误",
|
||||
},
|
||||
},
|
||||
dangerDialog: {
|
||||
title: "危险操作",
|
||||
|
||||
@@ -56,6 +56,54 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
|
||||
return statements;
|
||||
}
|
||||
|
||||
export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.tableMeta.schema,
|
||||
tableName: options.tableMeta.tableName,
|
||||
});
|
||||
const statements: string[] = [];
|
||||
|
||||
for (const row of options.newRows) {
|
||||
const where = buildRowWhere(options.databaseType, options.columns, row);
|
||||
if (where) statements.push(`DELETE FROM ${table} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIndex of options.deletedRows) {
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const columns = options.columns.map((column) => quoteIdent(options.databaseType, column)).join(", ");
|
||||
const values = row.map((v) => formatGridSqlLiteral(v, options.databaseType)).join(", ");
|
||||
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
|
||||
}
|
||||
|
||||
for (const [rowIndex, changes] of options.dirtyRows) {
|
||||
const row = options.rows[rowIndex];
|
||||
if (!row) continue;
|
||||
const afterRow = [...row];
|
||||
for (const [columnIndex, value] of changes) {
|
||||
afterRow[columnIndex] = value;
|
||||
}
|
||||
const sets = changes
|
||||
.map(
|
||||
([columnIndex]) =>
|
||||
`${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`,
|
||||
)
|
||||
.join(", ");
|
||||
const where = [
|
||||
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, afterRow),
|
||||
...changes.map(([columnIndex, value]) =>
|
||||
buildColumnPredicate(options.databaseType, options.columns[columnIndex], value),
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" AND ");
|
||||
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
export function formatGridSqlLiteral(value: GridCellValue, databaseType?: DatabaseType): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
@@ -80,6 +128,16 @@ function buildPrimaryKeyWhere(
|
||||
.join(" AND ");
|
||||
}
|
||||
|
||||
function buildRowWhere(databaseType: DatabaseType | undefined, columns: string[], row: GridCellValue[]): string {
|
||||
return columns.map((column, index) => buildColumnPredicate(databaseType, column, row[index])).join(" AND ");
|
||||
}
|
||||
|
||||
function buildColumnPredicate(databaseType: DatabaseType | undefined, column: string, value: GridCellValue): string {
|
||||
const ident = quoteIdent(databaseType, column);
|
||||
if (value === null || value === undefined) return `${ident} IS NULL`;
|
||||
return `${ident} = ${formatGridSqlLiteral(value, databaseType)}`;
|
||||
}
|
||||
|
||||
function quoteIdent(databaseType: DatabaseType | undefined, name: string): string {
|
||||
return quoteTableIdentifier(databaseType, name);
|
||||
}
|
||||
|
||||
@@ -498,6 +498,7 @@ export async function mongoDeleteDocument(
|
||||
// --- History ---
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
connection_id?: string;
|
||||
connection_name: string;
|
||||
database: string;
|
||||
sql: string;
|
||||
@@ -505,6 +506,12 @@ export interface HistoryEntry {
|
||||
execution_time_ms: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer";
|
||||
operation?: string;
|
||||
target?: string;
|
||||
affected_rows?: number | null;
|
||||
rollback_sql?: string | null;
|
||||
details_json?: string | null;
|
||||
}
|
||||
|
||||
export async function saveHistory(entry: HistoryEntry): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user