diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 2bb75bd5c7..ab2db58bf2 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -3121,6 +3121,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) objectType: sqlObjectNavigationSourceKind(table), }; let sqlContent: string | undefined; + const formatDialect = props.formatDialect ?? sqlFormatDialectForDbType(props.databaseType); let metadataLoadFailed = false; // The persisted display DDL is canonical across the full-page and hover @@ -3135,7 +3136,6 @@ 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"; - 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); } @@ -3170,6 +3170,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) } if (fullColumns.length > 0) { sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment); + if (!settingsStore.editorSettings.generateSqlQuoteIdentifiers) sqlContent = omitDdlIdentifierQuotes(sqlContent, formatDialect); metadataLoadFailed = false; } } diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 6934860a14..6b8e45e041 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -27,7 +27,7 @@ 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 } from "@/lib/sql/ddlDisplay"; +import { formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges"; import { copyToClipboard } from "@/lib/common/clipboard"; import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; @@ -346,7 +346,9 @@ async function fetchDdl(force = false) { ddlLoading.value = true; try { const { ddl } = await loadObjectDdl(ddlRequest(), { force }); - ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter); + const dialect = sqlFormatDialectForDbType(databaseType.value); + const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); + ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect); ddlFetched.value = true; } catch (e: any) { ddlContent.value = `-- Error: ${e?.message || e}`; @@ -1500,7 +1502,9 @@ async function hydrateRestoredDraftFromDatabase() { if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) { try { const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }); - ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter); + const dialect = sqlFormatDialectForDbType(databaseType.value); + const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); + ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); } catch { @@ -2158,7 +2162,9 @@ async function loadStructure( if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) { try { const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }, { force: options.forceDdl }); - ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter); + const dialect = sqlFormatDialectForDbType(databaseType.value); + const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); + ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); } catch { diff --git a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts index d844d4a6ef..252b40aac4 100644 --- a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { qualifiedTableName, qualifyTableReferencesInSql, quoteTableDataIdentifier, quoteTableIdentifier } from "@/lib/table/tableSelectSql"; +import { encodeSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers"; +import { qualifiedTableName, qualifyTableReferencesInSql, quoteTableDataIdentifier, quoteTableIdentifier, quoteTableIdentifierIfNeeded } from "@/lib/table/tableSelectSql"; describe("qualifiedTableName — Doris/StarRocks multi-catalog", () => { it("prefixes external catalog for Doris (no schema)", () => { @@ -119,6 +120,13 @@ describe("qualifiedTableName — SQLite attached databases", () => { }); }); +describe("qualifiedTableName — SQL Server linked servers", () => { + it("retains required brackets when optional quoting is disabled", () => { + const schema = encodeSqlServerLinkedSchema({ server: "REMOTE SERVER", catalog: "sales", schema: "dbo" }); + expect(qualifiedTableName({ databaseType: "sqlserver", schema, tableName: "order", quoteIdentifiers: false })).toBe("[REMOTE SERVER].sales.dbo.[order]"); + }); +}); + describe("qualifiedTableName — schema-aware JDBC profiles", () => { it("qualifies Phoenix tables without forcing identifier quotes", () => { expect(qualifiedTableName({ databaseType: "jdbc", driverProfile: "phoenix", schema: "DEMO", tableName: "STUDENT" })).toBe("DEMO.STUDENT"); @@ -145,6 +153,24 @@ describe("qualifiedTableName — GBase 8s", () => { }); describe("quoteTableIdentifier", () => { + it("keeps only required quotes when identifier quoting is disabled", () => { + expect(quoteTableIdentifierIfNeeded("oracle", "DBX_TEST")).toBe("DBX_TEST"); + expect(quoteTableIdentifierIfNeeded("oracle", "Order")).toBe('"Order"'); + expect(quoteTableIdentifierIfNeeded("oracle", "ORDER")).toBe('"ORDER"'); + expect(quoteTableIdentifierIfNeeded("dameng", "DBX_TEST")).toBe("DBX_TEST"); + expect(quoteTableIdentifierIfNeeded("dameng", "order detail")).toBe('"order detail"'); + expect(quoteTableIdentifierIfNeeded("postgres", "dbx_test")).toBe("dbx_test"); + expect(quoteTableIdentifierIfNeeded("postgres", "Order")).toBe('"Order"'); + expect(quoteTableIdentifierIfNeeded("mysql", "dbx_test")).toBe("dbx_test"); + expect(quoteTableIdentifierIfNeeded("mysql", "order")).toBe("`order`"); + }); + + it("uses a JDBC driver's reported quote for required identifiers", () => { + expect(quoteTableIdentifierIfNeeded("jdbc", "student", '"')).toBe("student"); + expect(quoteTableIdentifierIfNeeded("jdbc", "Order", '"')).toBe('"Order"'); + expect(quoteTableIdentifierIfNeeded("jdbc", "order detail", "`")).toBe("`order detail`"); + }); + it("backtick-quotes mysql identifiers", () => { expect(quoteTableIdentifier("mysql", "orders")).toBe("`orders`"); }); diff --git a/apps/desktop/src/lib/__tests__/table/tableSqlTemplates.spec.ts b/apps/desktop/src/lib/__tests__/table/tableSqlTemplates.spec.ts index 0ab64f3c5f..2bc4e327cc 100644 --- a/apps/desktop/src/lib/__tests__/table/tableSqlTemplates.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableSqlTemplates.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildTableSelectTemplate } from "@/lib/table/tableSqlTemplates"; +import { buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates"; describe("buildTableSelectTemplate", () => { it("can omit identifier quotes for a new-query table reference", () => { @@ -13,4 +13,32 @@ describe("buildTableSelectTemplate", () => { }), ).toBe("SELECT *\nFROM public.dbx_smoke;"); }); + + it("keeps required quotes for columns when identifier quoting is disabled", () => { + expect( + buildTableSelectTemplate({ + databaseType: "dameng", + tableName: "DBX_TEST", + columns: [ + { name: "ORDER", data_type: "VARCHAR" }, + { name: "CUSTOMER_NAME", data_type: "VARCHAR" }, + ], + quoteIdentifiers: false, + }), + ).toBe('SELECT "ORDER", CUSTOMER_NAME\nFROM DBX_TEST;'); + }); + + it("applies the same identifier policy to update templates", () => { + expect( + buildTableUpdateTemplate({ + databaseType: "oracle", + tableName: "DBX_TEST", + columns: [ + { name: "ID", data_type: "NUMBER", is_primary_key: true }, + { name: "ORDER", data_type: "VARCHAR" }, + ], + quoteIdentifiers: false, + }), + ).toBe("UPDATE DBX_TEST\nSET \"ORDER\" = 'ORDER_value'\nWHERE ID = 0;"); + }); }); diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index 056933b3d5..6452eaec5a 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -3,7 +3,7 @@ import { isSchemaAware, usesDatabaseObjectTreeMode } from "@/lib/database/databa import { jdbcDriverProfileUsesSchemaQualification } from "@/lib/database/jdbcDialect"; import * as api from "@/lib/backend/api.ts"; import { parseSqlServerLinkedSchema, sqlServerLinkedTableName } from "@/lib/database/sqlServerLinkedServers.ts"; -import { isExplicitlyQuotedSqlIdentifier, quoteGaussDbJdbcIdentifier } from "@/lib/sql/sqlIdentifier.ts"; +import { isExplicitlyQuotedSqlIdentifier, quoteGaussDbJdbcIdentifier, requiresDamengIdentifierQuote, requiresMysqlIdentifierQuote, requiresOracleIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier.ts"; import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; import { sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model"; import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens"; @@ -33,7 +33,7 @@ export interface BuildTableSelectSqlOptions { database?: string; /** Include the active database when this dialect supports `database.table` references. */ includeDatabaseName?: boolean; - /** Emit bare identifiers instead of dialect-specific identifier quotes. */ + /** Omit optional identifier quotes while retaining quotes required by the dialect. */ quoteIdentifiers?: boolean; } @@ -105,14 +105,68 @@ export function quoteTableDataIdentifier(databaseType: DatabaseType | undefined, return quoteTableIdentifier(databaseType, name); } +function quoteWithDelimiter(name: string, delimiter: string): string { + if (delimiter === "[") return `[${name.replace(/\]/g, "]]")}]`; + return `${delimiter}${name.replaceAll(delimiter, delimiter + delimiter)}${delimiter}`; +} + +function requiresIdentifierQuote(databaseType: DatabaseType | undefined, name: string, identifierQuote?: string): boolean { + if (isExplicitlyQuotedSqlIdentifier(name)) return false; + switch (databaseType) { + case "mysql": + case "clickhouse": + case "hive": + case "argo": + case "kyuubi": + case "impala": + case "spark": + case "databricks": + case "databend": + case "tdengine": + case "access": + case "doris": + case "starrocks": + case "goldendb": + return requiresMysqlIdentifierQuote(name); + case "oracle": + return requiresOracleIdentifierQuote(name); + case "dameng": + return requiresDamengIdentifierQuote(name); + case "postgres": + case "gaussdb": + case "opengauss": + case "kingbase": + return identifierQuote === "`" ? requiresMysqlIdentifierQuote(name) : requiresPostgresIdentifierQuote(name); + case "sqlserver": + return requiresMysqlIdentifierQuote(name); + case "jdbc": + return identifierQuote === "`" ? requiresMysqlIdentifierQuote(name) : requiresPostgresIdentifierQuote(name); + default: + return requiresMysqlIdentifierQuote(name); + } +} + +/** + * Omits optional identifier quotes while preserving quotes needed for reserved, + * mixed-case, or otherwise non-bare identifiers. + */ +export function quoteTableIdentifierIfNeeded(databaseType: DatabaseType | undefined, name: string, identifierQuote?: string): string { + if (isExplicitlyQuotedSqlIdentifier(name) || !requiresIdentifierQuote(databaseType, name, identifierQuote)) return name; + if (databaseType === "jdbc" && identifierQuote) return quoteWithDelimiter(name, identifierQuote); + if ((databaseType === "postgres" || databaseType === "gaussdb" || databaseType === "opengauss" || databaseType === "kingbase") && identifierQuote) { + return quoteGaussDbJdbcIdentifier(name, identifierQuote); + } + return quoteTableIdentifier(databaseType, name); +} + function quoteCypherIdentifier(name: string): string { return `\`${name.replace(/`/g, "``")}\``; } export function qualifiedTableName(options: Pick): string { const { databaseType, driverProfile, identifierQuote, schema, tableName, catalog, database, includeDatabaseName, quoteIdentifiers } = options; - const quoteTable = (name: string) => (quoteIdentifiers === false ? name : quoteTableIdentifier(databaseType, name)); - const quoteTableData = (name: string) => (quoteIdentifiers === false ? name : quoteTableDataIdentifier(databaseType, name, identifierQuote)); + const quoteTable = (name: string) => (quoteIdentifiers === false ? quoteTableIdentifierIfNeeded(databaseType, name, identifierQuote) : quoteTableIdentifier(databaseType, name)); + const quoteTableData = (name: string) => (quoteIdentifiers === false ? quoteTableIdentifierIfNeeded(databaseType, name, identifierQuote) : quoteTableDataIdentifier(databaseType, name, identifierQuote)); if (databaseType === "informix" && driverProfile?.trim().toLowerCase() === "gbase8s") { return quoteTableData(tableName); } @@ -175,7 +229,9 @@ export function qualifiedTableName(options: Pick quoteTableIdentifierIfNeeded(databaseType, name)).join(".") : sqlServerLinkedTableName(linked, tableName); + } } return `${quoteTable(schema)}.${quoteTable(tableName)}`; } diff --git a/apps/desktop/src/lib/table/tableSqlTemplates.ts b/apps/desktop/src/lib/table/tableSqlTemplates.ts index 5982944d9f..3fd4e5b220 100644 --- a/apps/desktop/src/lib/table/tableSqlTemplates.ts +++ b/apps/desktop/src/lib/table/tableSqlTemplates.ts @@ -1,5 +1,5 @@ import type { ColumnInfo, DatabaseType } from "@/types/database"; -import { metricRangeQuery, qualifiedTableName, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; +import { metricRangeQuery, qualifiedTableName, quoteTableDataIdentifier, quoteTableIdentifierIfNeeded } from "@/lib/table/tableSelectSql"; export interface TableSqlTemplateOptions { databaseType?: DatabaseType; @@ -9,7 +9,7 @@ export interface TableSqlTemplateOptions { catalog?: string; database?: string; includeDatabaseName?: boolean; - /** Emit bare table identifiers instead of dialect-specific quotes. */ + /** Omit optional identifier quotes while retaining quotes required by the dialect. */ quoteIdentifiers?: boolean; tableName: string; columns?: ColumnInfo[]; @@ -116,7 +116,7 @@ function templateTableName(options: TableSqlTemplateOptions): string { } function templateIdentifier(options: TableSqlTemplateOptions, name: string): string { - return quoteTableDataIdentifier(options.databaseType, name, options.identifierQuote); + return options.quoteIdentifiers === false ? quoteTableIdentifierIfNeeded(options.databaseType, name, options.identifierQuote) : quoteTableDataIdentifier(options.databaseType, name, options.identifierQuote); } function buildWhereClause(options: TableSqlTemplateOptions, primaryKeys: ColumnInfo[]): string {