fix(gbase8s): make view source saves safer
This commit is contained in:
25 files changed
+1343
-129
No files matched your search
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { ObjectSourceKind } from "@/types/database";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -22,6 +23,7 @@ const props = withDefaults(
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
objectType?: ObjectSourceKind;
|
||||
/** SQL dialect for syntax highlighting. Non-PG/non-MSSQL databases fall back to MySQL (same as QueryEditor's source viewer). */
|
||||
dialect: "mysql" | "postgres" | "sqlserver";
|
||||
/** SQL formatter dialect. Kept separate from the syntax-highlighting dialect because several PG-compatible DBs highlight as MySQL. */
|
||||
@@ -56,7 +58,7 @@ watch(
|
||||
ddlLoading.value = true;
|
||||
try {
|
||||
const schema = props.schema || props.database;
|
||||
const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName);
|
||||
const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType);
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
} catch (e: any) {
|
||||
ddlError.value = e?.message || String(e);
|
||||
@@ -171,7 +173,7 @@ function retry() {
|
||||
ddlContent.value = "";
|
||||
const schema = props.schema || props.database;
|
||||
api
|
||||
.getTableDdl(props.connectionId, props.database, schema, props.tableName)
|
||||
.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType)
|
||||
.then(async (ddl) => {
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
})
|
||||
|
||||
@@ -54,9 +54,8 @@ import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDa
|
||||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||||
import { buildDropObjectSql, buildDropTableSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, supportsDropTableCascade, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, objectSourceSaveExecutionMode, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
|
||||
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
|
||||
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql";
|
||||
import { buildViewDdl } from "@/lib/table/viewDdl";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
|
||||
@@ -123,6 +122,7 @@ const sourceContent = ref("");
|
||||
const sourceError = ref("");
|
||||
const sourceRow = ref<ObjectBrowserRow | null>(null);
|
||||
const sourceEditing = ref(false);
|
||||
const sourceCanEdit = ref(true);
|
||||
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||||
const tableStructureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||||
const sourceEditableText = ref("");
|
||||
@@ -426,12 +426,14 @@ async function openSource(row: ObjectBrowserRow) {
|
||||
sourceContent.value = "";
|
||||
sourceError.value = "";
|
||||
sourceEditing.value = false;
|
||||
sourceCanEdit.value = true;
|
||||
sourceEditableText.value = "";
|
||||
sourceDraft.value = "";
|
||||
sourceSaveError.value = "";
|
||||
sourceLoading.value = true;
|
||||
try {
|
||||
const result = await api.getObjectSource(props.connection.id, props.database, row.schema || selectedSchema.value || props.database, row.name, row.type as ObjectSourceKind);
|
||||
sourceCanEdit.value = result.editable !== false && row.type !== "SEQUENCE";
|
||||
const editable = await api.buildEditableObjectSource({
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
objectType: row.type as ObjectSourceKind,
|
||||
@@ -442,7 +444,10 @@ async function openSource(row: ObjectBrowserRow) {
|
||||
sourceEditableText.value = editable;
|
||||
sourceContent.value = await formatSqlForDisplay(editable, sourceFormatDialect.value, settingsStore.editorSettings.sqlFormatter);
|
||||
sourceDraft.value = editable;
|
||||
sourceEditing.value = row.type !== "SEQUENCE";
|
||||
sourceEditing.value = sourceCanEdit.value;
|
||||
if (!sourceCanEdit.value && row.type !== "SEQUENCE") {
|
||||
toast(t("objects.sourceReadOnly"), 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
sourceError.value = e?.message || String(e);
|
||||
} finally {
|
||||
@@ -450,27 +455,6 @@ async function openSource(row: ObjectBrowserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openViewDdl(row: ObjectBrowserRow) {
|
||||
if (row.type !== "VIEW" && row.type !== "MATERIALIZED_VIEW") return;
|
||||
try {
|
||||
const schema = row.schema || selectedSchema.value || props.database;
|
||||
const ddl =
|
||||
row.type === "MATERIALIZED_VIEW"
|
||||
? await api.getTableDdl(props.connection.id, props.database, schema, row.name, "MATERIALIZED_VIEW")
|
||||
: await buildViewDdl({
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
schema,
|
||||
name: row.name,
|
||||
source: (await api.getObjectSource(props.connection.id, props.database, schema, row.name, "VIEW")).source,
|
||||
});
|
||||
const formatted = await formatSqlForDisplay(ddl, sourceFormatDialect.value, settingsStore.editorSettings.sqlFormatter);
|
||||
const tabId = queryStore.createTab(props.connection.id, props.database, `DDL - ${row.name}`);
|
||||
queryStore.updateSql(tabId, formatted);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function openNewQuery(row: ObjectBrowserRow) {
|
||||
const tabId = queryStore.createTab(props.connection.id, props.database, row.name);
|
||||
queryStore.updateSql(
|
||||
@@ -1219,6 +1203,10 @@ async function copySource() {
|
||||
|
||||
function editSource() {
|
||||
if (!sourceRow.value || !sourceEditableText.value) return;
|
||||
if (!sourceCanEdit.value) {
|
||||
toast(t("objects.sourceReadOnly"), 3000);
|
||||
return;
|
||||
}
|
||||
sourceDraft.value = sourceEditableText.value;
|
||||
sourceSaveError.value = "";
|
||||
sourceEditing.value = true;
|
||||
@@ -1231,6 +1219,10 @@ function cancelEditSource() {
|
||||
}
|
||||
|
||||
async function saveSource() {
|
||||
if (!sourceCanEdit.value) {
|
||||
toast(t("objects.sourceReadOnly"), 3000);
|
||||
return;
|
||||
}
|
||||
if (!sourceRow.value || !sourceDraft.value.trim()) return;
|
||||
const row = sourceRow.value;
|
||||
const schema = row.schema || selectedSchema.value || props.database;
|
||||
@@ -1244,13 +1236,7 @@ async function saveSource() {
|
||||
name: row.name,
|
||||
source: sourceDraft.value,
|
||||
});
|
||||
for (const sql of statements) {
|
||||
if (objectSourceSaveExecutionMode(effectiveDatabaseType.value) === "single") {
|
||||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||||
} else {
|
||||
await api.executeScript(props.connection.id, props.database, sql, schema);
|
||||
}
|
||||
}
|
||||
await executeObjectSourceSave(props.connection.id, props.database, effectiveDatabaseType.value, statements, schema);
|
||||
toast(t("objects.sourceSaved"));
|
||||
sourceEditing.value = false;
|
||||
sourceDraft.value = "";
|
||||
@@ -1502,7 +1488,14 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
|
||||
{ label: t("contextMenu.editView"), action: () => openSource(item), icon: PencilLine },
|
||||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||||
{ label: t("contextMenu.viewDdl"), action: () => openViewDdl(item), icon: ScrollText },
|
||||
{
|
||||
label: t("contextMenu.viewDdl"),
|
||||
action: () => {
|
||||
ddlDialogTarget.value = item;
|
||||
showDdlDialog.value = true;
|
||||
},
|
||||
icon: ScrollText,
|
||||
},
|
||||
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
|
||||
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
|
||||
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
|
||||
@@ -1805,7 +1798,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<Button v-if="!sourceEditing" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="copySource">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button v-if="!sourceEditing" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
|
||||
<Button v-if="!sourceEditing && sourceCanEdit" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
|
||||
<PencilLine class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
|
||||
@@ -1949,7 +1942,17 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DdlViewDialog v-if="ddlDialogTarget" :connection-id="props.connection.id" :database="props.database" :schema="ddlDialogTarget.schema || selectedSchema" :table-name="ddlDialogTarget.name" :dialect="sourceDialect" :format-dialect="sourceFormatDialect" v-model:open="showDdlDialog" />
|
||||
<DdlViewDialog
|
||||
v-if="ddlDialogTarget"
|
||||
:connection-id="props.connection.id"
|
||||
:database="props.database"
|
||||
:schema="ddlDialogTarget.schema || selectedSchema"
|
||||
:table-name="ddlDialogTarget.name"
|
||||
:object-type="tableDdlObjectType(ddlDialogTarget.type)"
|
||||
:dialect="sourceDialect"
|
||||
:format-dialect="sourceFormatDialect"
|
||||
v-model:open="showDdlDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Clipboard, Loader2, PencilLine, RefreshCw } from "@lucide/vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
name: string;
|
||||
objectType: ObjectSourceKind;
|
||||
databaseType?: DatabaseType;
|
||||
dialect: "mysql" | "postgres" | "sqlserver";
|
||||
formatDialect?: SqlFormatDialect;
|
||||
initialEditing?: boolean;
|
||||
}>(),
|
||||
{
|
||||
initialEditing: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
saved: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const content = ref("");
|
||||
const editableText = ref("");
|
||||
const draft = ref("");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const editing = ref(false);
|
||||
const sourceEditable = ref(true);
|
||||
const error = ref("");
|
||||
const saveError = ref("");
|
||||
let loadSerial = 0;
|
||||
|
||||
const canEdit = computed(() => sourceEditable.value && props.objectType !== "SEQUENCE");
|
||||
const title = computed(() => `${editing.value ? t("contextMenu.editView") : t("contextMenu.viewSource")} - ${props.name}`);
|
||||
|
||||
watch(
|
||||
() => [props.open, props.connectionId, props.database, props.schema, props.name, props.objectType, props.initialEditing] as const,
|
||||
() => {
|
||||
if (props.open) void loadSource();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
|
||||
const serial = ++loadSerial;
|
||||
content.value = "";
|
||||
editableText.value = "";
|
||||
draft.value = "";
|
||||
error.value = "";
|
||||
saveError.value = "";
|
||||
sourceEditable.value = true;
|
||||
editing.value = false;
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!props.databaseType) throw new Error("Connection type is unavailable.");
|
||||
const schema = props.schema || props.database;
|
||||
const result = await api.getObjectSource(props.connectionId, props.database, schema, props.name, props.objectType);
|
||||
const editableAllowed = result.editable !== false;
|
||||
const editable = await buildEditableObjectSource({
|
||||
databaseType: props.databaseType,
|
||||
objectType: props.objectType,
|
||||
schema,
|
||||
name: props.name,
|
||||
source: result.source,
|
||||
});
|
||||
if (serial !== loadSerial) return;
|
||||
sourceEditable.value = editableAllowed;
|
||||
const formatted = await formatSqlForDisplay(editable, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
editableText.value = editable;
|
||||
content.value = formatted;
|
||||
draft.value = nextEditing && canEdit.value ? editable : "";
|
||||
editing.value = nextEditing && canEdit.value;
|
||||
if (nextEditing && !canEdit.value) {
|
||||
toast(t("objects.sourceReadOnly"), 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (serial === loadSerial) error.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (serial === loadSerial) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copySource() {
|
||||
if (!content.value) return;
|
||||
try {
|
||||
await copyToClipboard(content.value);
|
||||
toast(t("grid.copied"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function editSource() {
|
||||
if (!canEdit.value || !editableText.value) {
|
||||
if (!canEdit.value) toast(t("objects.sourceReadOnly"), 3000);
|
||||
return;
|
||||
}
|
||||
draft.value = editableText.value;
|
||||
saveError.value = "";
|
||||
editing.value = true;
|
||||
}
|
||||
|
||||
function cancelEditSource() {
|
||||
editing.value = false;
|
||||
draft.value = "";
|
||||
saveError.value = "";
|
||||
}
|
||||
|
||||
async function saveSource() {
|
||||
if (!canEdit.value) {
|
||||
toast(t("objects.sourceReadOnly"), 3000);
|
||||
return;
|
||||
}
|
||||
if (!draft.value.trim() || !props.databaseType) return;
|
||||
const schema = props.schema || props.database;
|
||||
saving.value = true;
|
||||
saveError.value = "";
|
||||
try {
|
||||
const statements = await buildExecutableObjectSourceStatements({
|
||||
databaseType: props.databaseType,
|
||||
objectType: props.objectType,
|
||||
schema,
|
||||
name: props.name,
|
||||
source: draft.value,
|
||||
});
|
||||
await executeObjectSourceSave(props.connectionId, props.database, props.databaseType, statements, schema);
|
||||
toast(t("objects.sourceSaved"));
|
||||
emit("saved");
|
||||
await loadSource(false);
|
||||
} catch (e: any) {
|
||||
saveError.value = e?.message || String(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="props.open" @update:open="(value) => emit('update:open', value)">
|
||||
<DialogContent class="h-[min(760px,calc(100dvh-2rem))] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-[900px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="loading" class="flex min-h-0 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span>{{ t("common.loading") }}</span>
|
||||
</div>
|
||||
<div v-else-if="error" class="flex min-h-0 flex-col items-center justify-center gap-3 text-sm">
|
||||
<p class="text-destructive">{{ error }}</p>
|
||||
<Button variant="outline" size="sm" @click="loadSource()">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{{ t("common.retry") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="editing" class="object-source-dialog-editor flex min-h-0 flex-col overflow-hidden rounded border" data-object-source-editor>
|
||||
<QueryEditor
|
||||
v-model="draft"
|
||||
class="min-h-0 flex-1"
|
||||
:connection-id="props.connectionId"
|
||||
:database="props.database"
|
||||
:schema="props.schema || props.database"
|
||||
:database-type="props.databaseType"
|
||||
:dialect="props.dialect"
|
||||
:format-dialect="props.formatDialect"
|
||||
force-word-wrap
|
||||
hide-execution-controls
|
||||
@save="saveSource"
|
||||
/>
|
||||
<div v-if="saveError" class="shrink-0 border-t px-3 py-2 text-xs text-destructive">
|
||||
{{ saveError }}
|
||||
</div>
|
||||
</div>
|
||||
<QueryEditor
|
||||
v-else
|
||||
:key="`${props.connectionId}:${props.database}:${props.schema || ''}:${props.name}:${props.objectType}`"
|
||||
:model-value="content"
|
||||
class="object-source-dialog-editor min-h-0 overflow-hidden rounded border"
|
||||
:connection-id="props.connectionId"
|
||||
:database="props.database"
|
||||
:schema="props.schema || props.database"
|
||||
:database-type="props.databaseType"
|
||||
:dialect="props.dialect"
|
||||
:format-dialect="props.formatDialect"
|
||||
force-word-wrap
|
||||
read-only
|
||||
hide-execution-controls
|
||||
data-object-source-preview
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeDialog">{{ t("common.close") }}</Button>
|
||||
<Button v-if="!editing" variant="outline" :disabled="!content" @click="copySource">
|
||||
<Clipboard class="h-4 w-4" />
|
||||
{{ t("grid.copy") }}
|
||||
</Button>
|
||||
<Button v-if="!editing && canEdit" variant="outline" :disabled="!editableText" @click="editSource">
|
||||
<PencilLine class="h-4 w-4" />
|
||||
{{ t("contextMenu.editView") }}
|
||||
</Button>
|
||||
<Button v-if="editing" variant="outline" :disabled="saving" @click="cancelEditSource">
|
||||
{{ t("objects.cancelEdit") }}
|
||||
</Button>
|
||||
<Button v-if="editing" :disabled="saving || !draft.trim()" @click="saveSource">
|
||||
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
|
||||
{{ t("objects.saveSource") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.object-source-dialog-editor :deep(.cm-editor),
|
||||
.object-source-dialog-editor :deep(.cm-scroller) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.object-source-dialog-editor :deep(.cm-scroller) {
|
||||
overflow: auto !important;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user