diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index ee6909c88d..d0363ae9e9 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -22,6 +22,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 { detectAndFormatStructured } from "@/lib/sql/autoFormat"; import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits"; @@ -2995,7 +2996,9 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) // (the same one the sidebar/object-source viewers use). Tables keep // the aligned column layout from reformatHoverDdl. const isViewObject = objectMetadataRequest.objectType === "VIEW" || objectMetadataRequest.objectType === "MATERIALIZED_VIEW"; - sqlContent = isViewObject ? await formatSqlForDisplay(rawDdl, props.formatDialect ?? sqlFormatDialectForDbType(props.databaseType), settingsStore.editorSettings.sqlFormatter) : reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName)); + const formatDialect = props.formatDialect ?? sqlFormatDialectForDbType(props.databaseType); + const formatted = isViewObject ? await formatSqlForDisplay(rawDdl, formatDialect, settingsStore.editorSettings.sqlFormatter) : reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName)); + sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect); } } catch (error) { console.warn(`[DBX] Failed to load table DDL for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error); diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index a01b0bbee1..4fcd550678 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -92,6 +92,8 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; import * as api from "@/lib/backend/api"; import { formatElapsedSeconds } from "@/lib/common/elapsedTime"; +import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; +import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import type { SqlInsertMode } from "@/lib/export/sqlInsertMode"; import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; import { createColumnDrafts } from "@/lib/table/tableStructureEditorState"; @@ -11400,7 +11402,8 @@ async function fetchDdl(force = settingsStore.editorSettings.refreshDdlOnOpen) { }, { force }, ); - ddlContent.value = ddl; + const formatDialect = sqlFormatDialectForDbType(resolvedDatabaseType.value); + ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? ddl : omitDdlIdentifierQuotes(ddl, formatDialect); } catch (e: any) { ddlContent.value = `-- Error: ${e}`; } finally { diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index a8d1937ced..076a0a015d 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -9,6 +9,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 { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -125,7 +126,9 @@ async function loadDdl(force = false) { }, { force }, ); - ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); + const formatDialect = props.formatDialect ?? props.dialect; + const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter); + ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, 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 dadfe54902..bad871d1f8 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -117,6 +117,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 { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; @@ -1149,7 +1150,8 @@ async function fetchTableDdl(force = settingsStore.editorSettings.refreshDdlOnOp try { const { ddl } = await loadObjectDdl(tableMetadataRequest(row), { force }); if (sidePanelGuard.isStale(epoch)) return; - tableDdlContent.value = ddl; + const formatDialect = sqlFormatDialectForDbType(effectiveDatabaseType.value); + tableDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? ddl : omitDdlIdentifierQuotes(ddl, 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 1baf58d59d..3e49cc08b4 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -153,6 +153,7 @@ import { buildEditableObjectSource, buildRoutineRenameObjectSourceStatements, su import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad"; 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, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; @@ -2090,7 +2091,11 @@ async function openSidebarMultiTableDdlTab(targets: Array formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseTypeForNode(target)), settingsStore.editorSettings.sqlFormatter), + async (ddl, target) => { + const formatDialect = sqlFormatDialectForDbType(databaseTypeForNode(target)); + const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter); + return settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect); + }, ); connectionStore.activeConnectionId = tabTarget.connectionId; const title = `DDL - ${targets.map((target) => target.label).join(", ")}`; diff --git a/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts b/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts new file mode 100644 index 0000000000..023f5de0ce --- /dev/null +++ b/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; + +describe("omitDdlIdentifierQuotes", () => { + it("removes safe MySQL identifier quotes without changing literals or comments", () => { + const ddl = "CREATE TABLE `demo_table` (`id` int DEFAULT 'a`b') COMMENT='`keep`'; -- `keep`"; + expect(omitDdlIdentifierQuotes(ddl, "mysql")).toBe("CREATE TABLE demo_table (id int DEFAULT 'a`b') COMMENT='`keep`'; -- `keep`"); + }); + + it("keeps quotes for names that require them", () => { + expect(omitDdlIdentifierQuotes("CREATE TABLE `order` (`with space` int)", "mysql")).toBe("CREATE TABLE `order` (`with space` int)"); + }); + + it("supports PostgreSQL and SQL Server identifier delimiters", () => { + expect(omitDdlIdentifierQuotes('CREATE TABLE "demo_table" ("id" integer)', "postgres")).toBe("CREATE TABLE demo_table (id integer)"); + expect(omitDdlIdentifierQuotes("CREATE TABLE [demo_table] ([id] int)", "sqlserver")).toBe("CREATE TABLE demo_table (id int)"); + }); + + it("keeps brackets on SQL Server reserved words while unquoting safe names", () => { + const ddl = "CREATE TABLE [demo_table] ([order] int, [key] int, [user] nvarchar(50), [id] int)"; + expect(omitDdlIdentifierQuotes(ddl, "sqlserver")).toBe("CREATE TABLE demo_table ([order] int, [key] int, [user] nvarchar(50), id int)"); + }); +}); diff --git a/apps/desktop/src/lib/sql/ddlDisplay.ts b/apps/desktop/src/lib/sql/ddlDisplay.ts new file mode 100644 index 0000000000..5fc28286fe --- /dev/null +++ b/apps/desktop/src/lib/sql/ddlDisplay.ts @@ -0,0 +1,43 @@ +import { requiresMysqlIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; +import { tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens"; +import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter"; + +const SIMPLE_SQLSERVER_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function canRenderUnquoted(identifier: string, dialect: SqlFormatDialect): boolean { + switch (dialect) { + case "mysql": + case "clickhouse": + return !requiresMysqlIdentifierQuote(identifier); + case "postgres": + case "sqlite": + case "oracle": + case "duckdb": + case "dameng": + case "generic": + return !requiresPostgresIdentifierQuote(identifier); + case "sqlserver": + return SIMPLE_SQLSERVER_IDENTIFIER.test(identifier) && !requiresMysqlIdentifierQuote(identifier.toLowerCase()); + default: + return false; + } +} + +/** Removes dialect identifier quotes from safe names while preserving strings, comments, and unsafe names. */ +export function omitDdlIdentifierQuotes(sql: string, dialect: SqlFormatDialect): string { + const tokens = tokenizeSqlSemantic(sql, dialect === "sqlserver" ? "sqlserver" : dialect); + const replacements: Array<{ start: number; end: number; value: string }> = []; + + for (const token of tokens) { + if (token.kind !== "quoted_identifier") continue; + const identifier = unquoteSqlSemanticIdentifier(token); + if (canRenderUnquoted(identifier, dialect)) replacements.push({ start: token.span.start, end: token.span.end, value: identifier }); + } + + let result = sql; + for (let index = replacements.length - 1; index >= 0; index -= 1) { + const replacement = replacements[index]!; + result = `${result.slice(0, replacement.start)}${replacement.value}${result.slice(replacement.end)}`; + } + return result; +}