diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 1dc8d5ad1a..84ccda0be5 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -48,7 +48,7 @@ import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview"; import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints"; import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns"; import { canFormatSqlForDatabaseType, formatSqlForDisplay, formatSqlForEditing, compressSqlText, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; -import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { detectAndFormatStructured } from "@/lib/sql/autoFormat"; import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits"; @@ -3515,7 +3515,8 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) // the aligned column layout from reformatHoverDdl. const isViewObject = objectMetadataRequest.objectType === "VIEW" || objectMetadataRequest.objectType === "MATERIALIZED_VIEW"; const formatted = isViewObject ? await formatSqlForDisplay(rawDdl, formatDialect, settingsStore.editorSettings.sqlFormatter) : reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName)); - sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect); + const unqualified = omitDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog); + sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect); } } catch (error) { console.warn(`[DBX] Failed to load table DDL for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error); @@ -3548,6 +3549,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) } if (fullColumns.length > 0) { sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment); + sqlContent = omitDdlDatabaseQualifier(sqlContent, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog); if (!settingsStore.editorSettings.generateSqlQuoteIdentifiers) sqlContent = omitDdlIdentifierQuotes(sqlContent, formatDialect); metadataLoadFailed = false; } diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index dc785e94f2..dc81b0a48f 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -11,7 +11,7 @@ import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; import { copyToClipboard } from "@/lib/common/clipboard"; import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; -import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -131,7 +131,8 @@ async function loadDdl(force = false) { ); const formatDialect = props.formatDialect ?? props.dialect; const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter); - rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect); + const unqualified = omitDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog); + rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect); } catch (e: any) { ddlError.value = e?.message || String(e); } finally { diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index d891fe7030..6269f40c38 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -122,7 +122,7 @@ import { useQueryStore } from "@/stores/queryStore"; import QueryEditor from "@/components/editor/QueryEditor.vue"; import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue"; import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; -import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; @@ -1189,7 +1189,8 @@ async function fetchTableDdl(force = settingsStore.editorSettings.refreshDdlOnOp const { ddl } = await loadObjectDdl(tableMetadataRequest(row), { force }); if (sidePanelGuard.isStale(epoch)) return; const formatDialect = sqlFormatDialectForDbType(effectiveDatabaseType.value); - rawTableDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? ddl : omitDdlIdentifierQuotes(ddl, formatDialect); + const unqualified = omitDdlDatabaseQualifier(ddl, formatDialect, effectiveDatabaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog); + rawTableDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect); loadedSuccessfully = true; } catch (e: any) { if (sidePanelGuard.isStale(epoch)) return; diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index e060677210..dd6533ff78 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -160,7 +160,6 @@ import { buildRenameObjectSql, buildRenameDatabaseSql, buildRenameDatabasePrefli import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; import { buildViewDdl } from "@/lib/table/viewDdl"; import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; -import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities"; import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionTableSqlSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { isObjectCacheInvalidationError } from "@/lib/metadata/objectCacheInvalidationError"; @@ -190,7 +189,7 @@ import { sidebarTreeArrowAction } from "@/lib/sidebar/sidebarTreeArrowNavigation import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty"; import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; import { runBatchTableDrop } from "@/lib/table/batchTableDrop"; -import { buildSidebarDdlTemplateSql } from "@/lib/sidebar/sidebarDdlTemplate"; +import { buildSidebarDdlTemplateSql, formatSidebarDdlTemplateForDisplay } from "@/lib/sidebar/sidebarDdlTemplate"; import { resolveSidebarDdlTargets } from "@/lib/sidebar/sidebarDdlTargets"; import { sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime"; import { formatSidebarTableCopyText, type FormatSidebarTableNamesOptions } from "@/lib/sidebar/sidebarTableNameCopy"; @@ -2152,7 +2151,7 @@ async function openSidebarMultiTableDdlTab(targets: Array { const formatDialect = sqlFormatDialectForDbType(databaseTypeForNode(target)); const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter); - return settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect); + return formatSidebarDdlTemplateForDisplay(formatted, formatDialect, databaseTypeForNode(target), settingsStore.editorSettings.generateSqlIncludeDatabaseName, settingsStore.editorSettings.generateSqlQuoteIdentifiers, target.catalog); }, ); connectionStore.activeConnectionId = tabTarget.connectionId; diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.ddlQualifierSetting.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.ddlQualifierSetting.spec.ts new file mode 100644 index 0000000000..611a71094c --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.ddlQualifierSetting.spec.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const runtimeSource = readFileSync(fileURLToPath(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url)), "utf8"); + +function asyncFunctionSource(name: string): string { + const start = runtimeSource.indexOf(`async function ${name}(`); + expect(start, `${name} not found`).toBeGreaterThan(-1); + const next = runtimeSource.indexOf("\nasync function ", start + 1); + return runtimeSource.slice(start, next === -1 ? undefined : next); +} + +describe("SidebarTreeRuntimeHost DDL database qualifier setting (#9421)", () => { + it("applies the setting with each target catalog before identifier quote formatting", () => { + const openDdl = asyncFunctionSource("openSidebarMultiTableDdlTab"); + const formatterCall = openDdl.indexOf("formatSidebarDdlTemplateForDisplay("); + + expect(formatterCall).toBeGreaterThan(-1); + expect(openDdl.slice(formatterCall, openDdl.indexOf(");", formatterCall))).toContain("target.catalog"); + }); +}); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index fa96699a7f..8512eb58ba 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -30,10 +30,10 @@ import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect import { useToast } from "@/composables/useToast"; import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter"; import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript"; -import { formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import { formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges"; import { copyToClipboard } from "@/lib/common/clipboard"; -import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; +import { formatSqlForDisplay, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCache"; @@ -341,6 +341,17 @@ function scheduleDdlEditorInit() { }); } +/** + * Applies the DDL display preferences (database qualifier + identifier quoting) + * to DDL shown by the structure editor, whether loaded from the server or + * generated from the pending structure changes. + */ +function formatDdlForDisplay(sql: string, dialect: SqlFormatDialect, generated = false): string { + const unqualified = omitDdlDatabaseQualifier(sql, dialect, databaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog); + if (settingsStore.editorSettings.generateSqlQuoteIdentifiers) return unqualified; + return generated ? formatGeneratedDdlIdentifierQuotes(unqualified, dialect, false) : omitDdlIdentifierQuotes(unqualified, dialect); +} + function ddlRequest() { return { connectionId: props.connectionId, @@ -359,7 +370,7 @@ async function fetchDdl(force = false) { const { ddl } = await loadObjectDdl(ddlRequest(), { force }); const dialect = sqlFormatDialectForDbType(databaseType.value); const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); - rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect); + rawDdlContent.value = formatDdlForDisplay(formatted, dialect); ddlFetched.value = true; } catch (e: any) { rawDdlContent.value = `-- Error: ${e?.message || e}`; @@ -1518,7 +1529,7 @@ async function hydrateRestoredDraftFromDatabase() { const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }); const dialect = sqlFormatDialectForDbType(databaseType.value); const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); - rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect); + rawDdlContent.value = formatDdlForDisplay(formatted, dialect); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); } catch { @@ -1785,7 +1796,7 @@ async function refreshSqlPreview() { if (requestId !== sqlPreviewRequestId) return; const statements = [...result.statements, ...ownerResult.statements, ...(mysqlAutoIncrementStatement ? [mysqlAutoIncrementStatement] : [])]; // SQLite type-change apply regenerates this revision-checked plan, so its preview must stay byte-for-byte aligned. - pendingStatements.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers !== false || hasSqliteTypeChange.value ? statements : statements.map((statement) => formatGeneratedDdlIdentifierQuotes(statement, sqlFormatDialectForDbType(databaseType.value), false)); + pendingStatements.value = hasSqliteTypeChange.value ? statements : statements.map((statement) => formatDdlForDisplay(statement, sqlFormatDialectForDbType(databaseType.value), true)); warnings.value = [...result.warnings, ...ownerResult.warnings]; sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined; } catch (e: any) { @@ -2178,7 +2189,7 @@ async function loadStructure( const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }, { force: options.forceDdl }); const dialect = sqlFormatDialectForDbType(databaseType.value); const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); - rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect); + rawDdlContent.value = formatDdlForDisplay(formatted, dialect); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); } catch { diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTab.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTab.spec.ts index 672a50e3b4..21d36b6086 100644 --- a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTab.spec.ts +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTab.spec.ts @@ -23,6 +23,16 @@ const mocks = vi.hoisted(() => ({ getTablePartitionStatus: vi.fn(), getTableOwner: vi.fn(), updateEditorSettings: vi.fn(), + editorSettings: { + structureEditorDensity: "compact", + sqlFormatter: {}, + tableColumnTemplateFields: [], + fontSize: 13, + fontFamily: "monospace", + theme: "default", + generateSqlIncludeDatabaseName: true, + generateSqlQuoteIdentifiers: true, + }, loadObjectDdl: vi.fn(), invalidateObjectDdl: vi.fn(), loadObjectMetadataFacet: vi.fn(), @@ -259,7 +269,7 @@ vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRe vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ - editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], fontSize: 13, fontFamily: "monospace", theme: "default", generateSqlQuoteIdentifiers: true }, + editorSettings: mocks.editorSettings, updateEditorSettings: mocks.updateEditorSettings, }), })); @@ -293,7 +303,7 @@ import TableStructureEditor from "@/components/structure/TableStructureEditor.vu const mountedApps: App[] = []; let lastDraft: Record | undefined; -async function mountStructureEditor() { +async function mountStructureEditor(props: Record = {}) { const root = document.createElement("div"); document.body.append(root); const app = createApp(TableStructureEditor, { @@ -301,6 +311,7 @@ async function mountStructureEditor() { database: "test", tableName: "users", initialTab: "columns", + ...props, "onUpdate:draft": (draft: Record | undefined) => { lastDraft = draft; }, @@ -373,6 +384,8 @@ async function editDdl(root: HTMLElement, script: string) { beforeEach(() => { vi.clearAllMocks(); lastDraft = undefined; + mocks.connection.db_type = "mysql"; + mocks.editorSettings.generateSqlIncludeDatabaseName = true; mocks.ensureConnected.mockResolvedValue(undefined); mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] }); mocks.executeBatch.mockResolvedValue({ rowsAffected: 0 }); @@ -437,6 +450,24 @@ describe("TableStructureEditor DDL tab", () => { expect(mocks.buildTableStructureChangeSql).not.toHaveBeenCalled(); }); + it("executes generated external-catalog DDL without dropping qualifiers", async () => { + mocks.connection.db_type = "doris"; + mocks.editorSettings.generateSqlIncludeDatabaseName = false; + mocks.buildTableStructureChangeSql.mockResolvedValue({ + statements: ["ALTER TABLE `iceberg`.`analytics`.`users` ADD COLUMN `nickname` STRING;"], + warnings: [], + }); + + const root = await mountStructureEditor({ database: "analytics", catalog: "iceberg" }); + buttonWithText(root, "structureEditor.addColumn").click(); + + await vi.waitFor(() => expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false), { timeout: 3000 }); + buttonWithText(root, "structureEditor.apply").click(); + + await vi.waitFor(() => expect(mocks.executeBatch).toHaveBeenCalledTimes(1), { timeout: 3000 }); + expect(mocks.executeBatch.mock.calls[0][2]).toEqual(["ALTER TABLE `iceberg`.`analytics`.`users` ADD COLUMN `nickname` STRING;"]); + }); + it("carries the edited script in the draft so the tab reports unsaved work", async () => { const root = await mountStructureEditor(); await openDdlTab(root); diff --git a/apps/desktop/src/composables/useDataGridTableMetadataLoaders.ts b/apps/desktop/src/composables/useDataGridTableMetadataLoaders.ts index f62674dbe6..5eeeadb2d0 100644 --- a/apps/desktop/src/composables/useDataGridTableMetadataLoaders.ts +++ b/apps/desktop/src/composables/useDataGridTableMetadataLoaders.ts @@ -1,6 +1,6 @@ import { computed, type ComputedRef, type Ref } from "vue"; import * as api from "@/lib/backend/api"; -import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; @@ -119,7 +119,8 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo try { const { ddl } = await loadObjectDdl(request, { force }); const formatDialect = sqlFormatDialectForDbType(options.resolvedDatabaseType.value); - state.ddlContent.value = options.settingsStore.editorSettings.generateSqlQuoteIdentifiers ? ddl : omitDdlIdentifierQuotes(ddl, formatDialect); + const unqualified = omitDdlDatabaseQualifier(ddl, formatDialect, options.resolvedDatabaseType.value, options.settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.tableMeta?.catalog); + state.ddlContent.value = options.settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect); } catch (error: any) { state.ddlContent.value = `-- Error: ${error}`; } finally { diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTemplate.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTemplate.spec.ts index 43cf6d1851..8fc94b2dd9 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTemplate.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarDdlTemplate.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { buildSidebarDdlTemplateSql, sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate"; +import { buildSidebarDdlTemplateSql, formatSidebarDdlTemplateForDisplay, sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate"; describe("sidebar DDL template", () => { it("preserves the single-target SQL", async () => { @@ -26,6 +26,30 @@ describe("sidebar DDL template", () => { expect(sql).toBe("CREATE TABLE one (id INT) /* one */;\n\nCREATE TABLE two (id INT) /* two */;\n"); }); + it("applies the database-name setting to a single target", async () => { + const sql = await buildSidebarDdlTemplateSql( + [{ databaseType: "oracle" as const, catalog: undefined }], + async () => 'CREATE TABLE "SYSTEM"."TEST" ("ID" NUMBER)', + async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "oracle", target.databaseType, false, true, target.catalog), + ); + + expect(sql).toBe('CREATE TABLE "TEST" ("ID" NUMBER)'); + }); + + it("keeps external-catalog qualifiers for multiple targets", async () => { + const targets = [ + { name: "events", catalog: "iceberg" }, + { name: "sessions", catalog: "iceberg" }, + ]; + const sql = await buildSidebarDdlTemplateSql( + targets, + async (target) => `CREATE TABLE \`iceberg\`.\`analytics\`.\`${target.name}\` (\`id\` bigint)`, + async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "mysql", "starrocks", false, false, target.catalog), + ); + + expect(sql).toBe("CREATE TABLE iceberg.analytics.events (id bigint);\n\nCREATE TABLE iceberg.analytics.sessions (id bigint);\n"); + }); + it("keeps DDL targets in the active SQL execution context", () => { const active = { id: "active", connectionId: "c1", database: "db1", catalog: "catalog1" }; const sameContext = { id: "same", connectionId: "c1", database: "db1", catalog: "catalog1" }; diff --git a/apps/desktop/src/lib/sidebar/sidebarDdlTemplate.ts b/apps/desktop/src/lib/sidebar/sidebarDdlTemplate.ts index 002f763c9a..26a5c56a51 100644 --- a/apps/desktop/src/lib/sidebar/sidebarDdlTemplate.ts +++ b/apps/desktop/src/lib/sidebar/sidebarDdlTemplate.ts @@ -1,4 +1,7 @@ import { joinExportedDdls } from "@/lib/export/ddlExport"; +import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter"; +import type { DatabaseType } from "@/types/database"; interface SidebarDdlExecutionTarget { connectionId: string; @@ -17,3 +20,8 @@ export async function buildSidebarDdlTemplateSql(targets: readonly T[], loadD } return parts.length === 1 ? parts[0]! : joinExportedDdls(parts); } + +export function formatSidebarDdlTemplateForDisplay(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, quoteIdentifiers: boolean, catalog?: string): string { + const unqualified = omitDdlDatabaseQualifier(sql, dialect, databaseType, includeDatabaseName, catalog); + return quoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, dialect); +} diff --git a/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts b/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts index 01876d35b3..3d87216ce8 100644 --- a/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts +++ b/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts @@ -1,5 +1,58 @@ import { describe, expect, it } from "vitest"; -import { formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import { formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; + +describe("omitDdlDatabaseQualifier", () => { + it("drops the schema qualifier from Oracle table DDL without touching tablespace references", () => { + const ddl = 'CREATE TABLE "SYSTEM"."TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";'; + expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE TABLE "TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";'); + }); + + it("drops the qualifier from generated ALTER statements and keeps column references", () => { + const ddl = 'ALTER TABLE "SYSTEM"."TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "SYSTEM"."TEST"."CDE" IS \'note\';'; + expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('ALTER TABLE "TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "TEST"."CDE" IS \'note\';'); + }); + + it("drops the qualifier from PostgreSQL, MySQL, and Dameng table references", () => { + expect(omitDdlDatabaseQualifier('CREATE TABLE "public"."users" ("id" integer)', "postgres", "postgres", false)).toBe('CREATE TABLE "users" ("id" integer)'); + expect(omitDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` int)", "mysql", "mysql", false)).toBe("CREATE TABLE `events` (`id` int)"); + expect(omitDdlDatabaseQualifier('ALTER TABLE "APP"."T1" ADD ("C1" INT);', "dameng", "dameng", false)).toBe('ALTER TABLE "T1" ADD ("C1" INT);'); + }); + + it("drops both qualifiers of CREATE INDEX ... ON and DROP INDEX targets", () => { + const ddl = 'CREATE INDEX "SYSTEM"."IDX_T" ON "SYSTEM"."TEST" ("ABC"); DROP INDEX "SYSTEM"."IDX_T";'; + expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE INDEX "IDX_T" ON "TEST" ("ABC"); DROP INDEX "IDX_T";'); + }); + + it("handles comma-separated DROP TABLE lists and TRUNCATE", () => { + expect(omitDdlDatabaseQualifier('DROP TABLE "public"."a", "public"."b";', "postgres", "postgres", false)).toBe('DROP TABLE "a", "b";'); + expect(omitDdlDatabaseQualifier('TRUNCATE TABLE "public"."a";', "postgres", "postgres", false)).toBe('TRUNCATE TABLE "a";'); + }); + + it("keeps the qualifier when the preference is enabled, the dialect needs it, or the type is unknown", () => { + const ddl = 'ALTER TABLE "SYSTEM"."TEST" ADD ("CDE" CLOB);'; + expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", true)).toBe(ddl); + expect(omitDdlDatabaseQualifier("ALTER TABLE [dbo].[t] ADD [c] int;", "sqlserver", "sqlserver", false)).toBe("ALTER TABLE [dbo].[t] ADD [c] int;"); + expect(omitDdlDatabaseQualifier(ddl, "oracle", undefined, false)).toBe(ddl); + }); + + it("keeps Doris and StarRocks external-catalog qualifiers", () => { + const dorisDdl = "ALTER TABLE `iceberg`.`analytics`.`events` ADD COLUMN `source` STRING;"; + const starrocksDdl = "CREATE TABLE `hive`.`events` (`id` bigint);"; + + expect(omitDdlDatabaseQualifier(dorisDdl, "mysql", "doris", false, "iceberg")).toBe(dorisDdl); + expect(omitDdlDatabaseQualifier(starrocksDdl, "mysql", "starrocks", false, "hive")).toBe(starrocksDdl); + }); + + it("drops Doris and StarRocks internal-catalog database qualifiers", () => { + expect(omitDdlDatabaseQualifier("ALTER TABLE `analytics`.`events` ADD COLUMN `source` STRING;", "mysql", "doris", false, "internal")).toBe("ALTER TABLE `events` ADD COLUMN `source` STRING;"); + expect(omitDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` bigint);", "mysql", "starrocks", false, "internal")).toBe("CREATE TABLE `events` (`id` bigint);"); + }); + + it("leaves statements it does not understand untouched", () => { + const ddl = "CREATE SEQUENCE SYSTEM.SEQ START WITH 1; SELECT SYSTEM.TEST.ID FROM SYSTEM.TEST;"; + expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe(ddl); + }); +}); describe("omitDdlIdentifierQuotes", () => { it("removes safe MySQL identifier quotes without changing literals or comments", () => { diff --git a/apps/desktop/src/lib/sql/ddlDisplay.ts b/apps/desktop/src/lib/sql/ddlDisplay.ts index 8c7dd57e8f..db3fbc69c8 100644 --- a/apps/desktop/src/lib/sql/ddlDisplay.ts +++ b/apps/desktop/src/lib/sql/ddlDisplay.ts @@ -1,6 +1,9 @@ import { requiresDamengIdentifierQuote, requiresMysqlIdentifierQuote, requiresOracleIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; -import { tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens"; +import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens"; +import type { SqlSemanticToken } from "@/lib/sql/semantic/types"; import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter"; +import { dropsSchemaQualifier } from "@/lib/table/tableSelectSql"; +import type { DatabaseType } from "@/types/database"; const SIMPLE_SQLSERVER_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -74,3 +77,167 @@ export function formatGeneratedDdlIdentifierQuotes(sql: string, dialect: SqlForm } return result; } + +interface DdlSpan { + start: number; + end: number; +} + +/** Object keywords that can follow `CREATE` once its modifiers are consumed. */ +const DDL_CREATE_OBJECT_WORDS = new Set(["table", "view", "index"]); + +/** + * Modifiers that may appear between `CREATE` and the object keyword. Anything + * outside this set (`function`, `procedure`, `sequence`, ...) is treated as an + * object kind we do not rewrite, which keeps unrelated DDL untouched. + */ +const DDL_CREATE_MODIFIER_WORDS = new Set(["global", "local", "materialized", "or", "replace", "temporary", "temp", "unlogged", "unique", "clustered", "nonclustered", "columnstore", "fulltext", "spatial", "external"]); + +const DDL_ALTER_OBJECT_WORDS = new Set(["table", "view", "index"]); +const DDL_DROP_OBJECT_WORDS = new Set(["table", "view", "index"]); +const DDL_COMMENT_OBJECT_WORDS = new Set(["table", "view", "index", "materialized"]); + +/** Skips an optional keyword sequence, returning the original index when it does not match. */ +function skipDdlWords(tokens: readonly SqlSemanticToken[], index: number, end: number, words: readonly string[]): number { + let cursor = index; + for (const word of words) { + const token = tokens[cursor]; + if (cursor >= end || !token || token.kind !== "word" || token.normalized !== word) return index; + cursor += 1; + } + return cursor; +} + +/** Token indexes of every identifier part of the qualified name starting at `start`. */ +function readDdlQualifiedName(tokens: readonly SqlSemanticToken[], start: number, end: number): number[] | undefined { + if (start >= end || !tokenIsIdentifier(tokens[start])) return undefined; + const parts = [start]; + let cursor = start + 1; + while (cursor + 1 < end && tokens[cursor]!.text === "." && tokenIsIdentifier(tokens[cursor + 1])) { + parts.push(cursor + 1); + cursor += 2; + } + return parts; +} + +function findDdlKeyword(tokens: readonly SqlSemanticToken[], start: number, end: number, word: string): number { + for (let cursor = start; cursor < end; cursor += 1) { + const token = tokens[cursor]!; + if (token.depth === 0 && token.kind === "word" && token.normalized === word) return cursor; + } + return -1; +} + +/** + * Records the span covering the qualifier chain of a qualified name, keeping + * only the trailing `keep` parts (`SYSTEM.TEST` with `keep: 1` removes `SYSTEM.`). + */ +function collectDdlQualifierRemoval(tokens: readonly SqlSemanticToken[], parts: readonly number[], keep: number, out: DdlSpan[]): void { + const firstKept = parts.length - keep; + if (firstKept <= 0) return; + out.push({ start: tokens[parts[0]!]!.span.start, end: tokens[parts[firstKept]!]!.span.start }); +} + +function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[], from: number, to: number, out: DdlSpan[]): void { + let index = from; + while (index < to && tokens[index]!.kind === "comment") index += 1; + const head = tokens[index]; + if (!head || head.kind !== "word") return; + + if (head.normalized === "create") { + let cursor = index + 1; + while (cursor < to) { + const token = tokens[cursor]!; + if (token.kind !== "word") return; + if (DDL_CREATE_OBJECT_WORDS.has(token.normalized)) break; + if (!DDL_CREATE_MODIFIER_WORDS.has(token.normalized)) return; + cursor += 1; + } + const objectWord = tokens[cursor]; + if (!objectWord) return; + cursor = skipDdlWords(tokens, cursor + 1, to, ["if", "not", "exists"]); + const nameParts = readDdlQualifiedName(tokens, cursor, to); + if (!nameParts) return; + collectDdlQualifierRemoval(tokens, nameParts, 1, out); + if (objectWord.normalized === "index") { + const onIndex = findDdlKeyword(tokens, nameParts[nameParts.length - 1]! + 1, to, "on"); + if (onIndex < 0) return; + const tableParts = readDdlQualifiedName(tokens, onIndex + 1, to); + if (tableParts) collectDdlQualifierRemoval(tokens, tableParts, 1, out); + } + return; + } + + if (head.normalized === "alter") { + const objectWord = tokens[index + 1]; + if (!objectWord || objectWord.kind !== "word" || !DDL_ALTER_OBJECT_WORDS.has(objectWord.normalized)) return; + const nameParts = readDdlQualifiedName(tokens, skipDdlWords(tokens, index + 2, to, ["if", "exists"]), to); + if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, 1, out); + return; + } + + if (head.normalized === "drop") { + const objectWord = tokens[index + 1]; + if (!objectWord || objectWord.kind !== "word" || !DDL_DROP_OBJECT_WORDS.has(objectWord.normalized)) return; + let cursor = skipDdlWords(tokens, index + 2, to, ["if", "exists"]); + for (;;) { + const nameParts = readDdlQualifiedName(tokens, cursor, to); + if (!nameParts) return; + collectDdlQualifierRemoval(tokens, nameParts, 1, out); + cursor = nameParts[nameParts.length - 1]! + 1; + if (tokens[cursor]?.text !== ",") return; + cursor += 1; + } + } + + if (head.normalized === "truncate") { + let cursor = index + 1; + if (tokens[cursor]?.kind === "word" && ["table", "only"].includes(tokens[cursor]!.normalized)) cursor += 1; + const nameParts = readDdlQualifiedName(tokens, cursor, to); + if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, 1, out); + return; + } + + if (head.normalized === "comment") { + if (tokens[index + 1]?.normalized !== "on") return; + const objectWord = tokens[index + 2]; + if (!objectWord || objectWord.kind !== "word") return; + const isColumn = objectWord.normalized === "column"; + if (!isColumn && !DDL_COMMENT_OBJECT_WORDS.has(objectWord.normalized)) return; + const nameParts = readDdlQualifiedName(tokens, index + 3, to); + // `COMMENT ON COLUMN t.c` keeps `t.c`, so only the schema qualifier goes. + if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, isColumn ? 2 : 1, out); + } +} + +/** + * Drops the schema/database qualifier from the target object of DDL statements + * (`CREATE/ALTER/DROP TABLE`, `CREATE INDEX ... ON`, `COMMENT ON ...`, + * `TRUNCATE TABLE`) so table DDL honors the "Include database name in generated + * SQL" preference the same way generated SELECT and copy-as-INSERT SQL do + * (#9421). Databases whose objects cannot be addressed without their qualifier + * keep it, mirroring `dropsSchemaQualifier`. + */ +export function omitDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, catalog?: string): string { + if (!dropsSchemaQualifier(databaseType, includeDatabaseName, catalog)) return sql; + const tokens = tokenizeSqlSemantic(sql, dialect === "sqlserver" ? "sqlserver" : dialect); + if (tokens.some((token) => token.closed === false)) return sql; + + const removals: DdlSpan[] = []; + let statementStart = 0; + for (let index = 0; index <= tokens.length; index += 1) { + const token = tokens[index]; + if (token && !(token.kind === "punctuation" && token.text === ";" && token.depth === 0)) continue; + collectDdlStatementQualifierRemovals(tokens, statementStart, index, removals); + statementStart = index + 1; + } + if (removals.length === 0) return sql; + + removals.sort((left, right) => left.start - right.start); + let result = sql; + for (let index = removals.length - 1; index >= 0; index -= 1) { + const removal = removals[index]!; + result = `${result.slice(0, removal.start)}${result.slice(removal.end)}`; + } + return result; +} diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index 03f36b55b8..7af12302e9 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -44,8 +44,9 @@ const DATABASE_QUALIFIED_TABLE_TYPES = new Set(["mysql", "clickhou // name" on schema-aware engines — except for databases that can only address // objects through their full qualified name (`catalog.schema.table` / // `database.schema.table`), where dropping it would break the query. -export function dropsSchemaQualifier(databaseType: DatabaseType | undefined, includeDatabaseName?: boolean): boolean { - return includeDatabaseName === false && databaseType !== undefined && !DATABASE_SCHEMA_QUALIFIED_TYPES.has(databaseType); +export function dropsSchemaQualifier(databaseType: DatabaseType | undefined, includeDatabaseName?: boolean, catalog?: string): boolean { + if (includeDatabaseName !== false || databaseType === undefined || DATABASE_SCHEMA_QUALIFIED_TYPES.has(databaseType)) return false; + return !catalog || catalog === "internal" || (databaseType !== "doris" && databaseType !== "starrocks"); } /** @@ -55,12 +56,7 @@ export function dropsSchemaQualifier(databaseType: DatabaseType | undefined, inc * SELECT templates do (#9326). */ export function tableMetaWithoutOptionalDatabaseQualifier(tableMeta: T | undefined, databaseType: DatabaseType | undefined, includeDatabaseName?: boolean): T | undefined { - if (!tableMeta || !dropsSchemaQualifier(databaseType, includeDatabaseName)) return tableMeta; - // Doris/StarRocks external-catalog tables are only addressable through the - // 3-part `catalog.database.table` form; stripping the middle segment would - // retarget the generated SQL, so keep the qualifiers (same rule as - // `qualifiedTableName`). - if (tableMeta.catalog && tableMeta.catalog !== "internal" && (databaseType === "doris" || databaseType === "starrocks")) return tableMeta; + if (!tableMeta || !dropsSchemaQualifier(databaseType, includeDatabaseName, tableMeta.catalog)) return tableMeta; if (tableMeta.schema === undefined && tableMeta.database === undefined) return tableMeta; return { ...tableMeta, schema: undefined, database: undefined }; } diff --git a/docs/screenshots/issue-9421-ddl-view-after.png b/docs/screenshots/issue-9421-ddl-view-after.png new file mode 100644 index 0000000000..6cd53cd1e1 Binary files /dev/null and b/docs/screenshots/issue-9421-ddl-view-after.png differ diff --git a/docs/screenshots/issue-9421-ddl-view-before.png b/docs/screenshots/issue-9421-ddl-view-before.png new file mode 100644 index 0000000000..e702b2f92a Binary files /dev/null and b/docs/screenshots/issue-9421-ddl-view-before.png differ diff --git a/docs/screenshots/issue-9421-structure-preview-after.png b/docs/screenshots/issue-9421-structure-preview-after.png new file mode 100644 index 0000000000..c76467918c Binary files /dev/null and b/docs/screenshots/issue-9421-structure-preview-after.png differ diff --git a/docs/screenshots/issue-9421-structure-preview-before.png b/docs/screenshots/issue-9421-structure-preview-before.png new file mode 100644 index 0000000000..6886e058ad Binary files /dev/null and b/docs/screenshots/issue-9421-structure-preview-before.png differ