diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index f0bec0d5ce..a41e606d97 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -41,6 +41,7 @@ import { supportsSourceBackedRoutineRename, } from "@/lib/objectSourceEditor"; import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql"; +import { buildViewDdl } from "@/lib/viewDdl"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import QueryEditor from "@/components/editor/QueryEditor.vue"; @@ -215,6 +216,29 @@ async function openSource(row: ObjectBrowserRow) { } } +async function openViewDdl(row: ObjectBrowserRow) { + if (row.type !== "VIEW") return; + try { + const result = await api.getObjectSource( + props.connection.id, + props.database, + row.schema || selectedSchema.value || props.database, + row.name, + "VIEW", + ); + const ddl = buildViewDdl({ + databaseType: props.connection.db_type, + schema: row.schema || selectedSchema.value || props.database, + name: row.name, + source: result.source, + }); + const tabId = queryStore.createTab(props.connection.id, props.database, `DDL - ${row.name}`); + queryStore.updateSql(tabId, ddl); + } catch (e: any) { + toast(e?.message || String(e), 5000); + } +} + function openNewQuery(row: ObjectBrowserRow) { const tabId = queryStore.createTab(props.connection.id, props.database, row.name); queryStore.updateSql( @@ -659,6 +683,9 @@ watch( {{ t("contextMenu.viewSource") }} + + {{ t("contextMenu.viewDdl") }} + {{ t("contextMenu.renameObject") }} diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index c3d1710a7f..1d32368f5b 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -103,6 +103,7 @@ import { } from "@/lib/createDatabaseSql"; import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } from "@/lib/objectRenameSql"; import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/objectSourceEditor"; +import { buildViewDdl } from "@/lib/viewDdl"; import { hexToRgba } from "@/lib/color"; import { focusSidebarRenameInput, shouldPreventRenameCloseAutoFocus } from "@/lib/sidebarRenameFocus"; import { hasTreeNodeDatabaseContext } from "@/lib/treeNodeContext"; @@ -696,6 +697,32 @@ function viewObjectSource() { }); } +function viewObjectDdl() { + const node = props.node; + if (node.type !== "view" || !node.connectionId || !node.database) return; + const schema = node.schema || node.database; + connectionStore + .ensureConnected(node.connectionId) + .then(() => { + connectionStore.activeConnectionId = node.connectionId!; + return api.getObjectSource(node.connectionId!, node.database!, schema, node.label, "VIEW"); + }) + .then((result) => { + const connection = connectionStore.getConfig(node.connectionId!); + const ddl = buildViewDdl({ + databaseType: connection?.db_type, + schema, + name: node.label, + source: result.source, + }); + const tabId = queryStore.createTab(node.connectionId!, node.database!, `DDL - ${node.label}`); + queryStore.updateSql(tabId, ddl); + }) + .catch((e: any) => { + toast(e?.message || String(e), 5000); + }); +} + function requestDropObject() { showDropObjectConfirm.value = true; } @@ -1981,6 +2008,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr {{ t("contextMenu.viewSource") }} + + {{ t("contextMenu.viewDdl") }} + {{ t("contextMenu.editStructure") }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index c3f656875b..e3450080cb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -708,6 +708,7 @@ export default { confirmTruncateTableMessage: 'Are you sure you want to truncate "{name}"? This will remove all rows.', dropTableSuccess: 'Table "{name}" dropped', viewSource: "View Source", + viewDdl: "View DDL", dropView: "Drop View", dropProcedure: "Drop Procedure", dropFunction: "Drop Function", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ef18f2d5c4..6265f5e4b9 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -610,6 +610,7 @@ export default { confirmTruncateTableMessage: '¿Estás seguro de que deseas truncar "{name}"? Esto eliminará todas las filas.', dropTableSuccess: 'Tabla "{name}" eliminada', viewSource: "Ver código fuente", + viewDdl: "Ver DDL", dropView: "Eliminar vista", dropProcedure: "Eliminar procedimiento", dropFunction: "Eliminar función", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fd1aa3b00b..49f86a859b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -691,6 +691,7 @@ export default { confirmTruncateTableMessage: "确定要截断「{name}」吗?这将删除所有行。", dropTableSuccess: "表「{name}」已删除", viewSource: "查看源码", + viewDdl: "查看 DDL", dropView: "删除视图", dropProcedure: "删除存储过程", dropFunction: "删除函数", diff --git a/apps/desktop/src/lib/viewDdl.ts b/apps/desktop/src/lib/viewDdl.ts new file mode 100644 index 0000000000..af7bcf4864 --- /dev/null +++ b/apps/desktop/src/lib/viewDdl.ts @@ -0,0 +1,50 @@ +import type { DatabaseType } from "@/types/database"; + +type BuildViewDdlInput = { + databaseType?: DatabaseType; + schema?: string | null; + name: string; + source: string; +}; + +const postgresLikeTypes = new Set([ + "postgres", + "redshift", + "gaussdb", + "kingbase", + "highgo", + "vastbase", + "opengauss", +]); + +function ensureSemicolon(sql: string): string { + const trimmed = sql.trim(); + return trimmed.endsWith(";") ? trimmed : `${trimmed};`; +} + +function quotePostgresIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + +function quoteMysqlIdentifier(value: string): string { + return `\`${value.replaceAll("`", "``")}\``; +} + +function qualifiedName(input: BuildViewDdlInput): string { + const parts = [input.schema, input.name].filter(Boolean) as string[]; + if (input.databaseType === "mysql" || input.databaseType === "goldendb") { + return parts.map(quoteMysqlIdentifier).join("."); + } + return parts.map(quotePostgresIdentifier).join("."); +} + +export function buildViewDdl(input: BuildViewDdlInput): string { + const source = input.source.trim(); + if (/^(?:CREATE|ALTER)\s+/i.test(source)) return ensureSemicolon(source); + + if (!input.databaseType || postgresLikeTypes.has(input.databaseType)) { + return `CREATE OR REPLACE VIEW ${qualifiedName(input)} AS\n${ensureSemicolon(source)}`; + } + + return `CREATE VIEW ${qualifiedName(input)} AS\n${ensureSemicolon(source)}`; +} diff --git a/packages/app-tests/viewDdl.test.ts b/packages/app-tests/viewDdl.test.ts new file mode 100644 index 0000000000..6b7b2f6972 --- /dev/null +++ b/packages/app-tests/viewDdl.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { buildViewDdl } from "../../apps/desktop/src/lib/viewDdl"; + +test("Postgres view DDL wraps a view definition body in CREATE OR REPLACE VIEW", () => { + const ddl = buildViewDdl({ + databaseType: "postgres", + schema: "public", + name: "active users", + source: " SELECT id, name FROM users WHERE active ", + }); + + assert.equal(ddl, 'CREATE OR REPLACE VIEW "public"."active users" AS\nSELECT id, name FROM users WHERE active;'); +}); + +test("view DDL keeps existing CREATE VIEW statements intact", () => { + const ddl = buildViewDdl({ + databaseType: "mysql", + schema: "reporting", + name: "active_users", + source: "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`", + }); + + assert.equal(ddl, "CREATE ALGORITHM=UNDEFINED VIEW `active_users` AS SELECT `id` FROM `users`;"); +}); + +test("sidebar view context menu exposes a separate DDL action", () => { + const source = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); + + assert.match(source, /function viewObjectDdl/); + assert.match(source, /buildViewDdl/); + assert.match(source, /contextMenu\.viewDdl/); +}); + +test("object browser view context menu exposes a separate DDL action", () => { + const source = readFileSync("apps/desktop/src/components/objects/ObjectBrowser.vue", "utf8"); + + assert.match(source, /function openViewDdl/); + assert.match(source, /buildViewDdl/); + assert.match(source, /contextMenu\.viewDdl/); +});