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/);
+});