diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index bfba57f39..b4a217e5f 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -60,6 +60,7 @@ import { isTauriRuntime } from "@/lib/tauriRuntime"; import { generateDatabaseExportId } from "@/lib/databaseExport"; import { copyToClipboard } from "@/lib/clipboard"; import { formatSqlInsert } from "@/lib/exportFormats"; +import { buildSingleDdlExportFileContent } from "@/lib/ddlExport"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; import { useConnectionStore } from "@/stores/connectionStore"; import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; @@ -799,7 +800,7 @@ async function exportStructure(row: ObjectBrowserRow) { try { const schema = row.schema || selectedSchema.value || props.database; const ddl = await api.getTableDdl(props.connection.id, props.database, schema, row.name, tableDdlObjectType(row.type)); - await saveFileContent(ddl + "\n", `${row.name}.sql`, "SQL", "sql"); + await saveFileContent(buildSingleDdlExportFileContent(ddl), `${row.name}.sql`, "SQL", "sql"); } catch (e: any) { console.error("Export structure failed:", e); } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 8e15b2bbf..11098030c 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -77,6 +77,7 @@ import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/table import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities"; import { copyNameForTreeNode, objectSourceKindForTreeNode, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick"; import { formatSqlInsert } from "@/lib/exportFormats"; +import { joinExportedDdls } from "@/lib/ddlExport"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; import { buildCreateDatabaseSql, buildDuckDbAttachDatabaseSql, duckDbAttachedDatabaseNameFromPath, supportsCreateDatabaseCharset, uniqueDuckDbAttachedDatabaseName } from "@/lib/createDatabaseSql"; import { @@ -2592,7 +2593,7 @@ async function exportStructure() { const ddl = await api.getTableDdl(target.connectionId, target.database, target.schema || target.database, target.label, tableDdlObjectTypeForNode(target.type)); parts.push(ddl.trim()); } - structurePreviewSql.value = `${parts.filter(Boolean).join("\n\n")}\n`; + structurePreviewSql.value = joinExportedDdls(parts); } catch (e: any) { structurePreviewError.value = e?.message || String(e); console.error("Export structure failed:", e); diff --git a/apps/desktop/src/lib/ddlExport.ts b/apps/desktop/src/lib/ddlExport.ts new file mode 100644 index 000000000..f672cc984 --- /dev/null +++ b/apps/desktop/src/lib/ddlExport.ts @@ -0,0 +1,15 @@ +export function ensureSqlStatementTerminator(sql: string): string { + const trimmed = sql.trim(); + if (!trimmed) return ""; + return trimmed.endsWith(";") ? trimmed : `${trimmed};`; +} + +export function buildSingleDdlExportFileContent(sql: string): string { + const statement = ensureSqlStatementTerminator(sql); + return statement ? `${statement}\n` : ""; +} + +export function joinExportedDdls(ddls: readonly string[]): string { + const statements = ddls.map(ensureSqlStatementTerminator).filter(Boolean); + return statements.length ? `${statements.join("\n\n")}\n` : ""; +} diff --git a/packages/app-tests/ddlExport.test.ts b/packages/app-tests/ddlExport.test.ts new file mode 100644 index 000000000..921850630 --- /dev/null +++ b/packages/app-tests/ddlExport.test.ts @@ -0,0 +1,28 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { buildSingleDdlExportFileContent, ensureSqlStatementTerminator, joinExportedDdls } from "../../apps/desktop/src/lib/ddlExport.ts"; + +test("ensureSqlStatementTerminator appends a trailing semicolon when missing", () => { + assert.equal(ensureSqlStatementTerminator("CREATE TABLE `users` (\n `id` int\n) ENGINE=InnoDB"), "CREATE TABLE `users` (\n `id` int\n) ENGINE=InnoDB;"); +}); + +test("ensureSqlStatementTerminator does not duplicate an existing trailing semicolon", () => { + assert.equal(ensureSqlStatementTerminator("CREATE VIEW v AS SELECT 1;"), "CREATE VIEW v AS SELECT 1;"); +}); + +test("joinExportedDdls separates exported objects with blank lines and terminates each statement", () => { + assert.equal( + joinExportedDdls([ + "CREATE TABLE `users` (`id` int)", + "CREATE TABLE `posts` (`id` int);", + ]), + "CREATE TABLE `users` (`id` int);\n\nCREATE TABLE `posts` (`id` int);\n", + ); +}); + +test("buildSingleDdlExportFileContent emits a single importable statement with one trailing newline", () => { + assert.equal( + buildSingleDdlExportFileContent(" CREATE TABLE `users` (`id` int) "), + "CREATE TABLE `users` (`id` int);\n", + ); +});