diff --git a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java index 7e6dedce2a..d61cd4628b 100644 --- a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java @@ -7,15 +7,21 @@ import java.sql.Connection; import java.sql.Driver; import java.sql.DriverManager; import java.sql.ResultSet; +import java.sql.Statement; import java.sql.Types; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { + private static final String GAUSSDB_COMPATIBILITY_SQL = + "SELECT datcompatibility FROM pg_catalog.pg_database WHERE datname = current_database()"; + private Connection connection; private String configuredDatabase = ""; + private String identifierQuote = ""; @Override public final Connection getConnection() { @@ -24,12 +30,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { @Override public String getIdentifierQuote() { - try { - String quote = requireConnected().getMetaData().getIdentifierQuoteString(); - return quote == null || quote.trim().isEmpty() ? "" : quote.trim(); - } catch (Exception ignored) { - return ""; - } + return identifierQuote; } @Override @@ -39,6 +40,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { connection = openConnection(params); configuredDatabase = params.getDatabase(); afterConnect(params, connection); + identifierQuote = resolveIdentifierQuote(params, connection); }); } @@ -218,9 +220,79 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { connection.close(); } connection = null; + identifierQuote = ""; }); } + private static String resolveIdentifierQuote(ConnectParams params, Connection connection) { + if (isPostgresCompatibleJdbc(params)) { + try (Statement statement = connection.createStatement()) { + statement.setQueryTimeout(5); + try (ResultSet result = statement.executeQuery(GAUSSDB_COMPATIBILITY_SQL)) { + if (result.next()) { + String quote = gaussdbIdentifierQuote(result.getString(1)); + if (quote != null) { + return quote; + } + } + } + } catch (Exception ignored) { + } + } + try { + String quote = connection.getMetaData().getIdentifierQuoteString(); + return quote == null || quote.trim().isEmpty() ? "" : quote.trim(); + } catch (Exception ignored) { + return ""; + } + } + + private static String gaussdbIdentifierQuote(String compatibilityMode) { + if (compatibilityMode == null) { + return null; + } + String normalized = compatibilityMode.trim().toUpperCase(Locale.ROOT); + if ("M".equals(normalized) || "B".equals(normalized) || "MYSQL".equals(normalized)) { + return "`"; + } + if ("A".equals(normalized) || "PG".equals(normalized) || "ORA".equals(normalized) || "POSTGRESQL".equals(normalized)) { + return "\""; + } + return null; + } + + private static boolean isPostgresCompatibleJdbc(ConnectParams params) { + StringBuilder identity = new StringBuilder(); + appendJdbcIdentity(identity, params.getConnection_string()); + appendJdbcIdentity(identity, params.getJdbc_driver_class()); + List driverPaths = params.getJdbc_driver_paths(); + if (driverPaths != null) { + for (String path : driverPaths) { + appendJdbcIdentity(identity, path); + } + } + String normalized = identity.toString().toLowerCase(Locale.ROOT); + return normalized.contains("jdbc:postgresql:") + || normalized.contains("jdbc:opengauss:") + || normalized.contains("jdbc:gaussdb:") + || normalized.contains("org.postgresql") + || normalized.contains("org.opengauss") + || normalized.contains("com.huawei.gaussdb") + || normalized.contains("postgresql") + || normalized.contains("opengauss") + || normalized.contains("gaussdb"); + } + + private static void appendJdbcIdentity(StringBuilder identity, String value) { + if (value == null || value.isEmpty()) { + return; + } + if (identity.length() > 0) { + identity.append('\n'); + } + identity.append(value); + } + protected abstract String driverClass(); protected abstract String buildJdbcUrl(ConnectParams params); diff --git a/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java index fbd677ef4d..7126552e89 100644 --- a/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java +++ b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java @@ -45,6 +45,61 @@ class AbstractJdbcAgentTest { assertEquals(1, tracking.closeCount); } + @Test + void resolvesAndCachesGaussdbCompatibilityIdentifierQuotes() { + for (String mode : Arrays.asList("M", "B", "MYSQL")) { + TrackingConnection tracking = new TrackingConnection(); + tracking.compatibilityMode = mode; + tracking.identifierQuote = "\""; + TestAgent agent = new TestAgent(tracking); + + agent.connect(postgresCompatibleParams()); + + assertEquals("`", agent.getIdentifierQuote()); + assertEquals("`", agent.getIdentifierQuote()); + assertEquals(1, tracking.compatibilityQueryCount); + } + + for (String mode : Arrays.asList("A", "PG", "ORA", "POSTGRESQL")) { + TrackingConnection tracking = new TrackingConnection(); + tracking.compatibilityMode = mode; + tracking.identifierQuote = "`"; + TestAgent agent = new TestAgent(tracking); + + agent.connect(postgresCompatibleParams()); + + assertEquals("\"", agent.getIdentifierQuote()); + assertEquals(1, tracking.compatibilityQueryCount); + } + } + + @Test + void fallsBackToJdbcMetadataWhenCompatibilityQueryFails() { + TrackingConnection tracking = new TrackingConnection(); + tracking.compatibilityQueryFails = true; + tracking.identifierQuote = "`"; + TestAgent agent = new TestAgent(tracking); + + agent.connect(postgresCompatibleParams()); + + assertEquals("`", agent.getIdentifierQuote()); + assertEquals(1, tracking.compatibilityQueryCount); + } + + @Test + void skipsCompatibilityQueryForOtherJdbcFamilies() { + TrackingConnection tracking = new TrackingConnection(); + tracking.identifierQuote = "`"; + TestAgent agent = new TestAgent(tracking); + + ConnectParams params = new ConnectParams(); + params.setConnection_string("jdbc:mysql://localhost/test"); + agent.connect(params); + + assertEquals("`", agent.getIdentifierQuote()); + assertEquals(0, tracking.compatibilityQueryCount); + } + @Test void testsConnectionsThroughSharedLifecycle() { TrackingConnection tracking = new TrackingConnection(); @@ -326,12 +381,22 @@ class AbstractJdbcAgentTest { } } + private static ConnectParams postgresCompatibleParams() { + ConnectParams params = new ConnectParams(); + params.setConnection_string("jdbc:postgresql://localhost/test"); + return params; + } + private static final class TrackingConnection { private final List calls = new ArrayList(); private int openCount; private int closeCount; private int isValidCount; private boolean autoCommit = true; + private String compatibilityMode; + private String identifierQuote = "\""; + private boolean compatibilityQueryFails; + private int compatibilityQueryCount; private Connection connection() { return proxy(Connection.class, new MethodHandler() { @@ -388,6 +453,9 @@ class AbstractJdbcAgentTest { if ("supportsTransactions".equals(method.getName())) { return true; } + if ("getIdentifierQuoteString".equals(method.getName())) { + return identifierQuote; + } return defaultValue(method.getReturnType()); } }); @@ -402,6 +470,13 @@ class AbstractJdbcAgentTest { calls.add("execute:" + args[0]); return true; } + if ("executeQuery".equals(name)) { + compatibilityQueryCount += 1; + if (compatibilityQueryFails) { + throw new IllegalStateException("compatibility query unavailable"); + } + return compatibilityResultSet(); + } if ("getResultSet".equals(name)) { return resultSet(); } @@ -432,6 +507,27 @@ class AbstractJdbcAgentTest { }); } + private ResultSet compatibilityResultSet() { + final boolean[] read = {false}; + return proxy(ResultSet.class, new MethodHandler() { + @Override + public Object handle(Method method, Object[] args) { + String name = method.getName(); + if ("next".equals(name)) { + if (read[0] || compatibilityMode == null) { + return false; + } + read[0] = true; + return true; + } + if ("getString".equals(name)) { + return compatibilityMode; + } + return defaultValue(method.getReturnType()); + } + }); + } + private ResultSet resultSet() { final int[] index = {-1}; return proxy(ResultSet.class, new MethodHandler() { diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index d856ca6fb6..8bcc8299bd 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -98,6 +98,7 @@ import { translateBackendError } from "@/i18n/backend-errors"; import { applyHiveKerberosSubmitConfig, hiveKerberosFormConfig, type HiveKerberosAuthMode } from "@/lib/database/hiveKerberosOptions"; import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudflareD1Connection } from "@/lib/connection/cloudflareD1"; import { buildElasticsearchExternalConfig, elasticsearchConnectionModeFromConfig, elasticsearchConnectivityCheckPathFromConfig, elasticsearchKibanaBasePathFromConfig, type ElasticsearchConnectionMode } from "@/lib/connection/elasticsearchKibanaProxy"; +import { gaussdbIdentifierQuoteStyle, setGaussdbIdentifierQuoteStyle, supportsGaussdbIdentifierQuoteStyle, type GaussdbIdentifierQuoteStyle } from "@/lib/database/jdbcDialect"; type DbOption = { value: string; label: string }; type DbCategoryKey = "sql" | "analytics" | "domestic" | "lightweight" | "document" | "graph_ai" | "timeseries" | "mq" | "registry_config"; @@ -450,6 +451,14 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] { const form = ref(defaultForm()); const noteTextareaRef = ref(null); +const showGaussdbIdentifierQuoteStyle = computed(() => supportsGaussdbIdentifierQuoteStyle(form.value)); +const gaussdbQuoteStyle = computed({ + get: () => gaussdbIdentifierQuoteStyle(form.value), + set: (style) => { + setGaussdbIdentifierQuoteStyle(form.value, style); + resetTestState(); + }, +}); function resizeNoteTextarea() { const textarea = noteTextareaRef.value; @@ -3220,6 +3229,10 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value); } else if (config.db_type === "sqlserver") { config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined; + } else if (supportsGaussdbIdentifierQuoteStyle(config)) { + const style = gaussdbIdentifierQuoteStyle(config); + config.external_config = undefined; + setGaussdbIdentifierQuoteStyle(config, style); } else { config.external_config = undefined; } @@ -6535,6 +6548,22 @@ function openExternalUrl(url: string) {
+
+ +
+ +

{{ t("connection.gaussdbIdentifierQuoteHint") }}

+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f8ecd8845f..a59ca9ae2d 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -503,6 +503,11 @@ export default { sqlServerLegacyCompatibilityModeEnable: "Enable", sqlServerLegacyCompatibilityModeHint: "For SQL Server instances limited to TLS 1.0, login-only encryption, or no encryption.\nSome features may be limited; use only on trusted networks.", sqlServerLegacyCompatibilityComponent: "SQL Server legacy compatibility component", + gaussdbIdentifierQuoteStyle: "Identifier compatibility", + gaussdbIdentifierQuoteAuto: "Auto (driver/default)", + gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (double quotes ")', + gaussdbIdentifierQuoteBacktick: "MySQL/M (backticks `)", + gaussdbIdentifierQuoteHint: "Auto uses JDBC driver metadata or the native GaussDB default. Select a mode manually only when generated SQL uses the wrong identifier quotes.", saveAndConnect: "Save & Connect", save: "Save", editTitle: "Edit Connection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 21f9b40ced..d62b6c87e9 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -404,6 +404,11 @@ export default withEnglishFallback({ sqlServerLegacyCompatibilityModeEnable: "Activar", sqlServerLegacyCompatibilityModeHint: "Para instancias de SQL Server limitadas a TLS 1.0, cifrado solo al iniciar sesión o sin cifrado.\nAlgunas funciones pueden estar limitadas; úsalo solo en redes de confianza.", sqlServerLegacyCompatibilityComponent: "Componente de compatibilidad heredada de SQL Server", + gaussdbIdentifierQuoteStyle: "Compatibilidad de identificadores", + gaussdbIdentifierQuoteAuto: "Automático (controlador/predeterminado)", + gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (comillas dobles ")', + gaussdbIdentifierQuoteBacktick: "MySQL/M (acentos graves `)", + gaussdbIdentifierQuoteHint: "El modo automático usa los metadatos del controlador JDBC o el valor predeterminado de GaussDB nativo. Seleccione un modo manualmente solo si el SQL generado usa comillas de identificador incorrectas.", saveAndConnect: "Guardar y conectar", save: "Guardar", editTitle: "Editar conexión", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 636b9a7765..9fed14dc09 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -403,6 +403,11 @@ export default withEnglishFallback({ sqlServerLegacyCompatibilityModeEnable: "Abilita", sqlServerLegacyCompatibilityModeHint: "Per istanze di SQL Server limitate a TLS 1.0, crittografia solo all'accesso o nessuna crittografia.\nAlcune funzionalità potrebbero essere limitate; usala solo su reti attendibili.", sqlServerLegacyCompatibilityComponent: "Componente di compatibilità legacy SQL Server", + gaussdbIdentifierQuoteStyle: "Compatibilità identificatori", + gaussdbIdentifierQuoteAuto: "Automatico (driver/predefinito)", + gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (virgolette doppie ")', + gaussdbIdentifierQuoteBacktick: "MySQL/M (accenti gravi `)", + gaussdbIdentifierQuoteHint: "La modalità automatica usa i metadati del driver JDBC o il valore predefinito di GaussDB nativo. Seleziona manualmente una modalità solo se l'SQL generato usa virgolette errate per gli identificatori.", saveAndConnect: "Salva & Connetti", save: "Salva", editTitle: "Modifica Connessione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1b67d595db..7f0159eb4b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -397,6 +397,11 @@ export default withEnglishFallback({ sqlServerLegacyCompatibilityModeEnable: "有効にする", sqlServerLegacyCompatibilityModeHint: "TLS 1.0、ログイン時のみの暗号化、または暗号化なしに限定された SQL Server への接続に使用します。\n一部の機能が制限される場合があるため、信頼できるネットワークでのみ使用してください。", sqlServerLegacyCompatibilityComponent: "SQL Server レガシー互換コンポーネント", + gaussdbIdentifierQuoteStyle: "識別子互換モード", + gaussdbIdentifierQuoteAuto: "自動(ドライバー/既定値)", + gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL(二重引用符 ")', + gaussdbIdentifierQuoteBacktick: "MySQL/M(バッククォート `)", + gaussdbIdentifierQuoteHint: "自動モードでは JDBC ドライバーのメタデータまたはネイティブ GaussDB の既定値を使用します。生成された SQL の識別子引用符が誤っている場合のみ手動で選択してください。", saveAndConnect: "保存して接続", save: "保存", editTitle: "接続を編集", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f0d54fa7ba..41d96d117c 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -404,6 +404,11 @@ export default withEnglishFallback({ sqlServerLegacyCompatibilityModeEnable: "Ativar", sqlServerLegacyCompatibilityModeHint: "Para instâncias do SQL Server limitadas a TLS 1.0, criptografia apenas no login ou sem criptografia.\nAlguns recursos podem ser limitados; use apenas em redes confiáveis.", sqlServerLegacyCompatibilityComponent: "Componente de compatibilidade legada do SQL Server", + gaussdbIdentifierQuoteStyle: "Compatibilidade de identificadores", + gaussdbIdentifierQuoteAuto: "Automático (driver/padrão)", + gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (aspas duplas ")', + gaussdbIdentifierQuoteBacktick: "MySQL/M (crases `)", + gaussdbIdentifierQuoteHint: "O modo automático usa os metadados do driver JDBC ou o padrão do GaussDB nativo. Selecione um modo manualmente apenas quando o SQL gerado usar aspas de identificador incorretas.", saveAndConnect: "Salvar e Conectar", save: "Salvar", editTitle: "Editar Conexão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a00c775b0a..1488dc8215 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -504,6 +504,11 @@ export default withEnglishFallback({ sqlServerLegacyCompatibilityModeEnable: "启用", sqlServerLegacyCompatibilityModeHint: "用于连接仅支持 TLS 1.0、仅登录阶段加密或不加密的 SQL Server;\n部分功能可能受限,请仅在可信网络中使用", sqlServerLegacyCompatibilityComponent: "SQL Server 旧版兼容组件", + gaussdbIdentifierQuoteStyle: "标识符兼容模式", + gaussdbIdentifierQuoteAuto: "自动(驱动/默认)", + gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL(双引号 ")', + gaussdbIdentifierQuoteBacktick: "MySQL/M(反引号 `)", + gaussdbIdentifierQuoteHint: "自动模式使用 JDBC 驱动元数据或原生 GaussDB 默认值。仅当生成的 SQL 使用了错误的标识符引号时手动选择。", saveAndConnect: "保存并连接", save: "保存", editTitle: "编辑连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 202c8cc258..2934d6cadd 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -403,6 +403,11 @@ export default withEnglishFallback({ sqlServerLegacyCompatibilityModeEnable: "啟用", sqlServerLegacyCompatibilityModeHint: "用於連線僅支援 TLS 1.0、僅登入階段加密或未加密的 SQL Server;\n部分功能可能受限,請僅在可信網路中使用", sqlServerLegacyCompatibilityComponent: "SQL Server 舊版相容元件", + gaussdbIdentifierQuoteStyle: "識別字相容模式", + gaussdbIdentifierQuoteAuto: "自動(驅動程式/預設)", + gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL(雙引號 ")', + gaussdbIdentifierQuoteBacktick: "MySQL/M(反引號 `)", + gaussdbIdentifierQuoteHint: "自動模式使用 JDBC 驅動程式中繼資料或原生 GaussDB 預設值。僅在產生的 SQL 使用錯誤的識別字引號時手動選擇。", saveAndConnect: "儲存並連線", save: "儲存", editTitle: "編輯連線", diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index a24baeb942..4f42774f95 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "vitest"; -import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "@/lib/database/jdbcDialect"; +import { + connectionObjectTreeNodeSchema, + connectionQueryExecutionSchema, + connectionShouldLoadIdentifierQuote, + connectionUsesDatabaseObjectTreeMode, + effectiveDatabaseTypeForConnection, + gaussdbIdentifierQuoteOverride, + gaussdbIdentifierQuoteStyle, + inferJdbcDialect, + setGaussdbIdentifierQuoteStyle, + supportsGaussdbIdentifierQuoteStyle, +} from "@/lib/database/jdbcDialect"; describe("jdbc dialect inference", () => { it("detects InterSystems IRIS and Caché JDBC connections", () => { @@ -65,6 +76,56 @@ describe("jdbc dialect inference", () => { expect(connectionUsesDatabaseObjectTreeMode(opengaussConnection)).toBe(false); }); + it("loads driver-reported identifier quotes for compatible JDBC connections", () => { + expect(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_paths: ["/drivers/gaussdb.jar"] })).toBe(true); + expect(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_class: "org.opengauss.Driver" })).toBe(true); + expect(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_class: "org.postgresql.Driver" })).toBe(true); + expect(connectionShouldLoadIdentifierQuote({ db_type: "kingbase" })).toBe(true); + expect(connectionShouldLoadIdentifierQuote({ db_type: "gaussdb" })).toBe(true); + expect( + connectionShouldLoadIdentifierQuote({ + db_type: "jdbc", + jdbc_driver_class: "org.postgresql.Driver", + external_config: { gaussdbIdentifierQuoteStyle: "backtick" }, + }), + ).toBe(false); + }); + + it("recognizes GaussDB reached through PostgreSQL-compatible JDBC drivers", () => { + const connection = { + db_type: "jdbc" as const, + connection_string: "jdbc:postgresql://localhost:5432/postgres", + jdbc_driver_class: "org.postgresql.Driver", + database_info: { productName: "GaussDB Kernel", driverName: "PostgreSQL JDBC Driver" }, + }; + + expect(inferJdbcDialect(connection)).toBe("gaussdb"); + expect(effectiveDatabaseTypeForConnection(connection)).toBe("gaussdb"); + expect(supportsGaussdbIdentifierQuoteStyle(connection)).toBe(true); + }); + + it("supports persisted GaussDB identifier quote overrides", () => { + const native = { db_type: "gaussdb" as const, external_config: undefined as unknown }; + const jdbc = { db_type: "jdbc" as const, jdbc_driver_paths: ["/drivers/gaussdb.jar"], external_config: { retained: true } as unknown }; + + expect(supportsGaussdbIdentifierQuoteStyle(native)).toBe(true); + expect(gaussdbIdentifierQuoteStyle(native)).toBe("auto"); + expect(gaussdbIdentifierQuoteOverride(native)).toBeUndefined(); + + setGaussdbIdentifierQuoteStyle(native, "backtick"); + expect(gaussdbIdentifierQuoteStyle(native)).toBe("backtick"); + expect(gaussdbIdentifierQuoteOverride(native)).toBe("`"); + + setGaussdbIdentifierQuoteStyle(jdbc, "double"); + expect(jdbc.external_config).toEqual({ retained: true, gaussdbIdentifierQuoteStyle: "double" }); + expect(gaussdbIdentifierQuoteOverride(jdbc)).toBe('"'); + expect(connectionShouldLoadIdentifierQuote(jdbc)).toBe(false); + + setGaussdbIdentifierQuoteStyle(jdbc, "auto"); + expect(jdbc.external_config).toEqual({ retained: true }); + expect(connectionShouldLoadIdentifierQuote(jdbc)).toBe(true); + }); + it("detects Dameng JDBC connections", () => { const damengConnection = { db_type: "jdbc" as const, diff --git a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts index 1fa39f6834..ae868630f4 100644 --- a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts @@ -54,6 +54,42 @@ describe("quoteTableIdentifier", () => { expect(quoteTableDataIdentifier("kingbase", "order detail", "`")).toBe("`order detail`"); }); + it("selectively quotes GaussDB JDBC identifiers with the driver-reported quote", () => { + expect(quoteTableDataIdentifier("gaussdb", "table_01", '"')).toBe("table_01"); + expect(quoteTableDataIdentifier("gaussdb", "MixedCase", '"')).toBe('"MixedCase"'); + expect(quoteTableDataIdentifier("gaussdb", "order", '"')).toBe('"order"'); + expect(quoteTableDataIdentifier("gaussdb", "order detail", '"')).toBe('"order detail"'); + expect(quoteTableDataIdentifier("gaussdb", 'already"quoted', '"')).toBe('"already""quoted"'); + expect(quoteTableDataIdentifier("gaussdb", '"AlreadyQuoted"', '"')).toBe('"AlreadyQuoted"'); + + expect(quoteTableDataIdentifier("gaussdb", "table_01", "`")).toBe("table_01"); + expect(quoteTableDataIdentifier("gaussdb", "MixedCase", "`")).toBe("`MixedCase`"); + expect(quoteTableDataIdentifier("gaussdb", "order", "`")).toBe("`order`"); + expect(quoteTableDataIdentifier("gaussdb", "order detail", "`")).toBe("`order detail`"); + expect(quoteTableDataIdentifier("gaussdb", "already`quoted", "`")).toBe("`already``quoted`"); + expect(quoteTableDataIdentifier("gaussdb", "`AlreadyQuoted`", "`")).toBe("`AlreadyQuoted`"); + }); + + it("uses detected GaussDB compatibility quotes through PostgreSQL-compatible JDBC dialects", () => { + for (const databaseType of ["postgres", "opengauss"] as const) { + expect(quoteTableDataIdentifier(databaseType, "table_01", "`")).toBe("table_01"); + expect(quoteTableDataIdentifier(databaseType, "MixedCase", "`")).toBe("`MixedCase`"); + expect(quoteTableDataIdentifier(databaseType, "order", "`")).toBe("`order`"); + expect(quoteTableDataIdentifier(databaseType, "order detail", "`")).toBe("`order detail`"); + expect(quoteTableDataIdentifier(databaseType, "`AlreadyQuoted`", "`")).toBe("`AlreadyQuoted`"); + expect(quoteTableDataIdentifier(databaseType, "MixedCase", '"')).toBe('"MixedCase"'); + } + }); + + it("preserves native GaussDB and openGauss quoting behavior", () => { + expect(quoteTableDataIdentifier("gaussdb", "table_01")).toBe('"table_01"'); + expect(quoteTableDataIdentifier("gaussdb", "MixedCase")).toBe('"MixedCase"'); + expect(quoteTableDataIdentifier("gaussdb", '"AlreadyQuoted"')).toBe('"AlreadyQuoted"'); + expect(quoteTableDataIdentifier("opengauss", "table_01")).toBe('"table_01"'); + expect(quoteTableDataIdentifier("opengauss", "MixedCase")).toBe('"MixedCase"'); + expect(quoteTableDataIdentifier("opengauss", '"AlreadyQuoted"')).toBe('"AlreadyQuoted"'); + }); + it("escapes Kingbase identifiers without maintaining a reserved-word list", () => { expect(quoteTableDataIdentifier("kingbase", "ANALYZE", "`")).toBe("`ANALYZE`"); expect(quoteTableDataIdentifier("kingbase", "AUTHORIZATION", '"')).toBe('"AUTHORIZATION"'); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 6f142e7ad6..12ed0a2080 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -2,7 +2,11 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; -type JdbcDialectConnection = Pick & Partial>; +type JdbcDialectConnection = Pick & Partial>; + +export type GaussdbIdentifierQuoteStyle = "auto" | "double" | "backtick"; + +const GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY = "gaussdbIdentifierQuoteStyle"; const DATABASE_AS_EXECUTION_SCHEMA_TYPES = new Set(["hive", "spark"]); @@ -34,7 +38,9 @@ const JDBC_ASE_PROFILE_PATTERNS = [/(?:^|[\s_-])ase(?:$|[\s_-])/i, /\bsap[\s_-]+ export function inferJdbcDialect(connection?: JdbcDialectConnection): DatabaseType | undefined { if (!connection || connection.db_type !== "jdbc") return undefined; - const haystack = [connection.driver_profile, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? [])].filter(Boolean).join("\n"); + const haystack = [connection.driver_profile, connection.driver_label, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? []), connection.database_info?.productName, connection.database_info?.serverComment, connection.database_info?.driverName] + .filter(Boolean) + .join("\n"); if (!haystack) return undefined; return JDBC_DIALECT_MATCHERS.find((matcher) => matcher.patterns.some((pattern) => pattern.test(haystack)))?.type; } @@ -57,6 +63,45 @@ export function effectiveDatabaseTypeForConnection(connection?: JdbcDialectConne return inferJdbcDialect(connection) ?? "jdbc"; } +export function connectionShouldLoadIdentifierQuote(connection: JdbcDialectConnection | undefined): boolean { + if (!connection) return false; + if (connection.db_type === "kingbase") return true; + if (gaussdbIdentifierQuoteStyle(connection) !== "auto") return false; + if (connection.db_type === "gaussdb") return true; + if (connection.db_type !== "jdbc") return false; + return ["gaussdb", "opengauss", "postgres"].includes(inferJdbcDialect(connection) ?? ""); +} + +export function supportsGaussdbIdentifierQuoteStyle(connection: JdbcDialectConnection | undefined): boolean { + return effectiveDatabaseTypeForConnection(connection) === "gaussdb"; +} + +export function gaussdbIdentifierQuoteStyle(connection: JdbcDialectConnection | undefined): GaussdbIdentifierQuoteStyle { + const external = externalConfigRecord(connection?.external_config); + const style = external[GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY]; + return style === "double" || style === "backtick" ? style : "auto"; +} + +export function gaussdbIdentifierQuoteOverride(connection: JdbcDialectConnection | undefined): string | undefined { + const style = gaussdbIdentifierQuoteStyle(connection); + if (style === "double") return '"'; + if (style === "backtick") return "`"; + return undefined; +} + +export function setGaussdbIdentifierQuoteStyle( + connection: Pick & Partial>, + style: GaussdbIdentifierQuoteStyle, +) { + const external = externalConfigRecord(connection.external_config); + if (style === "auto") { + delete external[GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY]; + } else { + external[GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY] = style; + } + connection.external_config = Object.keys(external).length > 0 ? external : undefined; +} + export function sqlSnippetDatabaseTypeForConnection(connection?: JdbcDialectConnection): DatabaseType | undefined { // ASE uses T-SQL snippets, but mapping it globally to SQL Server would also // enable incompatible SQL Server metadata and pagination behavior. @@ -142,3 +187,7 @@ function isJdbcAseProfile(connection?: JdbcDialectConnection): boolean { function isGbase8sProfile(driverProfile?: string): boolean { return driverProfile === "gbase8s"; } + +function externalConfigRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {}; +} diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 4f55f69153..84d52cb7ae 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -7,6 +7,7 @@ import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types"; import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; +import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; @@ -2781,124 +2782,11 @@ function unquoteIdentifier(value: string): string { } export function quoteSqlIdentifier(identifier: string, dialect?: "mysql" | "postgres" | "sqlserver"): string { - if (dialect !== "postgres" || !requiresPostgresIdentifierQuote(identifier)) return identifier; + if (dialect !== "postgres" || !requiresPostgresIdentifierQuote(identifier, POSTGRES_IDENTIFIER_KEYWORDS)) return identifier; return `"${identifier.replaceAll('"', '""')}"`; } -function requiresPostgresIdentifierQuote(identifier: string): boolean { - if (!/^[a-z_][a-z0-9_$]*$/.test(identifier)) return true; - return POSTGRES_IDENTIFIER_KEYWORDS.has(identifier); -} - -// PostgreSQL reserved_keyword + type_func_name_keyword categories (pg_get_keywords() -// catcodes R and T) — identifiers matching these cannot appear as a bare column -// reference and must be quoted. SQL_KEYWORDS alone misses most of them because it is -// a completion phrase list ("ORDER BY", not "order"). -const POSTGRES_RESERVED_KEYWORDS = [ - "all", - "analyse", - "analyze", - "and", - "any", - "array", - "as", - "asc", - "asymmetric", - "authorization", - "binary", - "both", - "case", - "cast", - "check", - "collate", - "collation", - "column", - "concurrently", - "constraint", - "create", - "cross", - "current_catalog", - "current_date", - "current_role", - "current_schema", - "current_time", - "current_timestamp", - "current_user", - "default", - "deferrable", - "desc", - "distinct", - "do", - "else", - "end", - "except", - "false", - "fetch", - "for", - "foreign", - "freeze", - "from", - "full", - "grant", - "group", - "having", - "ilike", - "in", - "initially", - "inner", - "intersect", - "into", - "is", - "isnull", - "join", - "lateral", - "leading", - "left", - "like", - "limit", - "localtime", - "localtimestamp", - "natural", - "not", - "notnull", - "null", - "offset", - "on", - "only", - "or", - "order", - "outer", - "overlaps", - "placing", - "primary", - "references", - "returning", - "right", - "select", - "session_user", - "similar", - "some", - "symmetric", - "system_user", - "table", - "tablesample", - "then", - "to", - "trailing", - "true", - "union", - "unique", - "user", - "using", - "variadic", - "verbose", - "when", - "where", - "window", - "with", -]; - -const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()).concat(POSTGRES_RESERVED_KEYWORDS)); +const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase())); function buildTableItems( context: Pick, diff --git a/apps/desktop/src/lib/sql/sqlIdentifier.ts b/apps/desktop/src/lib/sql/sqlIdentifier.ts new file mode 100644 index 0000000000..4e11af9001 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlIdentifier.ts @@ -0,0 +1,200 @@ +const SIMPLE_LOWER_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/; + +// PostgreSQL pg_get_keywords() categories R and T. GaussDB's PostgreSQL- +// compatible modes use the same identifier constraints for bare names. +const POSTGRES_RESERVED_IDENTIFIER_KEYWORDS = new Set([ + "all", + "analyse", + "analyze", + "and", + "any", + "array", + "as", + "asc", + "asymmetric", + "authorization", + "binary", + "both", + "case", + "cast", + "check", + "collate", + "collation", + "column", + "concurrently", + "constraint", + "create", + "cross", + "current_catalog", + "current_date", + "current_role", + "current_schema", + "current_time", + "current_timestamp", + "current_user", + "default", + "deferrable", + "desc", + "distinct", + "do", + "else", + "end", + "except", + "false", + "fetch", + "for", + "foreign", + "freeze", + "from", + "full", + "grant", + "group", + "having", + "ilike", + "in", + "initially", + "inner", + "intersect", + "into", + "is", + "isnull", + "join", + "lateral", + "leading", + "left", + "like", + "limit", + "localtime", + "localtimestamp", + "natural", + "not", + "notnull", + "null", + "offset", + "on", + "only", + "or", + "order", + "outer", + "overlaps", + "placing", + "primary", + "references", + "returning", + "right", + "select", + "session_user", + "similar", + "some", + "symmetric", + "system_user", + "table", + "tablesample", + "then", + "to", + "trailing", + "true", + "union", + "unique", + "user", + "using", + "variadic", + "verbose", + "when", + "where", + "window", + "with", +]); + +// GaussDB MySQL-compatible drivers report a backtick. These additional words +// cover MySQL-only names that cannot safely be emitted as bare identifiers. +const MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS = new Set([ + "accessible", + "auto_increment", + "change", + "database", + "databases", + "delayed", + "describe", + "div", + "dual", + "enclosed", + "escaped", + "explain", + "force", + "fulltext", + "high_priority", + "ignore", + "index", + "infile", + "key", + "keys", + "kill", + "linear", + "lines", + "load", + "lock", + "low_priority", + "master_ssl_verify_server_cert", + "maxvalue", + "mediumint", + "mod", + "no_write_to_binlog", + "optimize", + "optionally", + "outfile", + "partition", + "purge", + "range", + "read_write", + "regexp", + "release", + "rename", + "replace", + "require", + "rlike", + "schema", + "schemas", + "separator", + "show", + "spatial", + "sql_big_result", + "sql_calc_found_rows", + "sql_small_result", + "ssl", + "starting", + "straight_join", + "terminated", + "tinyint", + "unlock", + "unsigned", + "use", + "utc_date", + "utc_time", + "utc_timestamp", + "values", + "varbinary", + "varchar", + "write", + "xor", + "zerofill", +]); + +export function isExplicitlyQuotedSqlIdentifier(value: string): boolean { + if (value.length < 2) return false; + return (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("`") && value.endsWith("`")) || (value.startsWith("[") && value.endsWith("]")); +} + +export function requiresPostgresIdentifierQuote(identifier: string, additionalKeywords?: ReadonlySet): boolean { + if (!SIMPLE_LOWER_IDENTIFIER.test(identifier)) return true; + return POSTGRES_RESERVED_IDENTIFIER_KEYWORDS.has(identifier) || additionalKeywords?.has(identifier) === true; +} + +export function quoteGaussDbJdbcIdentifier(identifier: string, identifierQuote: string): string { + if (isExplicitlyQuotedSqlIdentifier(identifier)) return identifier; + const quote = identifierQuote.trim(); + if (!quote) return identifier; + const requiresQuote = !SIMPLE_LOWER_IDENTIFIER.test(identifier) || POSTGRES_RESERVED_IDENTIFIER_KEYWORDS.has(identifier) || (quote === "`" && MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS.has(identifier)); + if (!requiresQuote) return identifier; + return `${quote}${identifier.replaceAll(quote, quote + quote)}${quote}`; +} diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index c8c1f1c571..f3cf435021 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -2,6 +2,7 @@ import type { DatabaseType } from "@/types/database.ts"; import { isSchemaAware, usesDatabaseObjectTreeMode } from "@/lib/database/databaseCapabilities.ts"; import * as api from "@/lib/backend/api.ts"; import { parseSqlServerLinkedSchema, sqlServerLinkedTableName } from "@/lib/database/sqlServerLinkedServers.ts"; +import { isExplicitlyQuotedSqlIdentifier, quoteGaussDbJdbcIdentifier } from "@/lib/sql/sqlIdentifier.ts"; export interface BuildTableSelectSqlOptions { databaseType?: DatabaseType; @@ -22,6 +23,7 @@ export interface BuildTableSelectSqlOptions { } export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string { + if ((databaseType === "gaussdb" || databaseType === "opengauss") && isExplicitlyQuotedSqlIdentifier(name)) return name; if (databaseType === "iotdb") return name; // JDBC connections use the driver-reported identifier quote string // (DatabaseMetaData.getIdentifierQuoteString()) — pass through unquoted. @@ -36,6 +38,7 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam } export function quoteTableDataIdentifier(databaseType: DatabaseType | undefined, name: string, identifierQuote?: string): string { + if ((databaseType === "gaussdb" || databaseType === "opengauss" || databaseType === "postgres") && identifierQuote != null) return quoteGaussDbJdbcIdentifier(name, identifierQuote); if (databaseType === "kingbase" && identifierQuote != null) { if (!identifierQuote) return name; return `${identifierQuote}${name.replaceAll(identifierQuote, identifierQuote + identifierQuote)}${identifierQuote}`; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.gaussdbIdentifierQuote.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.gaussdbIdentifierQuote.spec.ts new file mode 100644 index 0000000000..da03138cb8 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.gaussdbIdentifierQuote.spec.ts @@ -0,0 +1,75 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function gaussdbConnection(style: "double" | "backtick"): ConnectionConfig { + return { + id: `gaussdb-${style}`, + name: "GaussDB", + db_type: "gaussdb", + host: "127.0.0.1", + port: 5432, + username: "gaussdb", + password: "", + database: "postgres", + external_config: { gaussdbIdentifierQuoteStyle: style }, + }; +} + +describe("connectionStore GaussDB identifier quote override", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("resolves the persisted compatibility mode before runtime driver metadata", async () => { + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const double = gaussdbConnection("double"); + const backtick = gaussdbConnection("backtick"); + store.connections = [double, backtick]; + + expect(store.connectionIdentifierQuote(double.id)).toBe('"'); + expect(store.connectionIdentifierQuote(backtick.id)).toBe("`"); + }); + + it("loads and caches auto compatibility quotes once per connection", async () => { + const config: ConnectionConfig = { + ...gaussdbConnection("double"), + id: "gaussdb-auto", + external_config: undefined, + }; + const connectionIdentifierQuote = vi.fn().mockResolvedValue("`"); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + connectDb: vi.fn().mockResolvedValue(config.id), + connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")), + connectionIdentifierQuote, + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + await store.addConnection(config); + await store.connect(config); + + expect(connectionIdentifierQuote).toHaveBeenCalledOnce(); + expect(connectionIdentifierQuote).toHaveBeenCalledWith(config.id); + expect(store.connectionIdentifierQuote(config.id)).toBe("`"); + expect(store.connectionIdentifierQuote(config.id)).toBe("`"); + expect(connectionIdentifierQuote).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 0ad2650a6c..7328f98bfe 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -61,7 +61,7 @@ import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; -import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbIdentifierQuoteOverride } from "@/lib/database/jdbcDialect"; import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree"; import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse"; @@ -499,6 +499,8 @@ export const useConnectionStore = defineStore("connection", () => { function connectionIdentifierQuote(connectionId?: string): string | undefined { if (!connectionId) return undefined; + const override = gaussdbIdentifierQuoteOverride(getConfig(connectionId)); + if (override != null) return override; return identifierQuotes.value[connectionId]; } @@ -511,7 +513,7 @@ export const useConnectionStore = defineStore("connection", () => { async function refreshConnectionIdentifierQuote(connectionId: string, config: ConnectionConfig) { clearConnectionIdentifierQuote(connectionId); - if (config.db_type !== "kingbase") return; + if (!connectionShouldLoadIdentifierQuote(config)) return; const quote = await api.connectionIdentifierQuote(connectionId).catch(() => undefined); if (quote != null) identifierQuotes.value = { ...identifierQuotes.value, [connectionId]: quote }; } diff --git a/crates/dbx-core/examples/table_import_live_bench.rs b/crates/dbx-core/examples/table_import_live_bench.rs index 64675c3cee..df01227f1a 100644 --- a/crates/dbx-core/examples/table_import_live_bench.rs +++ b/crates/dbx-core/examples/table_import_live_bench.rs @@ -255,6 +255,7 @@ fn write_xlsx(path: &Path, row_count: usize, column_count: usize, text_bytes: us sheet_name: Some("Benchmark".to_string()), columns: columns(column_count), column_types: Vec::new(), + column_comments: Vec::new(), rows: (0..row_count).map(|row_index| row(row_index, column_count, text_bytes)).collect(), numeric_column_right_align: false, })?; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 589fce2650..d445581bc0 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -2785,6 +2785,17 @@ impl AppState { .get(connection_id) .cloned() .ok_or_else(|| format!("Connection config not found: {connection_id}"))?; + if config.db_type == DatabaseType::Gaussdb { + let pool_key = self.get_or_create_pool(connection_id, database).await?; + let pool = { + let connections = self.connections.read().await; + match connections.get(&pool_key) { + Some(PoolKind::Postgres(pool)) => pool.clone(), + _ => return Ok(None), + } + }; + return Ok(db::postgres::gaussdb_identifier_quote(&pool).await); + } if !database_capabilities::is_agent_type(&config.db_type) { return Ok(None); } diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 2ba63096f1..50dbdf8bf2 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -770,7 +770,10 @@ pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinc } pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String { - let table = if options.database_type == Some(DatabaseType::Kingbase) { + let table = if crate::sql_dialect::uses_connection_identifier_quote( + options.database_type, + options.identifier_quote.as_deref(), + ) { crate::sql_dialect::table_data_qualified_table_name( options.database_type, options.schema.as_deref(), @@ -2691,7 +2694,7 @@ fn data_grid_qualified_table_name( table_name: &str, identifier_quote: Option<&str>, ) -> String { - if database_type == Some(DatabaseType::Kingbase) { + if crate::sql_dialect::uses_connection_identifier_quote(database_type, identifier_quote) { crate::sql_dialect::table_data_qualified_table_name(database_type, schema, table_name, identifier_quote) } else { crate::sql_dialect::qualified_table_name_with_catalog(database_type, catalog, schema, database, table_name) @@ -3191,6 +3194,33 @@ mod tests { #[test] fn builds_filter_conditions() { + for (database_type, identifier_quote, column_name, expected) in [ + (DatabaseType::Gaussdb, Some("\""), "column_01", "column_01 = 1"), + (DatabaseType::Gaussdb, Some("\""), "MixedCase", "\"MixedCase\" = 1"), + (DatabaseType::Gaussdb, Some("\""), "order", "\"order\" = 1"), + (DatabaseType::Gaussdb, Some("\""), "order detail", "\"order detail\" = 1"), + (DatabaseType::Gaussdb, Some("\""), "\"AlreadyQuoted\"", "\"AlreadyQuoted\" = 1"), + (DatabaseType::Gaussdb, Some("`"), "MixedCase", "`MixedCase` = 1"), + (DatabaseType::Postgres, Some("`"), "order", "`order` = 1"), + (DatabaseType::OpenGauss, Some("`"), "order detail", "`order detail` = 1"), + (DatabaseType::Gaussdb, None, "column_01", "\"column_01\" = 1"), + (DatabaseType::OpenGauss, None, "column_01", "\"column_01\" = 1"), + ] { + assert_eq!( + build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions { + database_type: Some(database_type), + identifier_quote: identifier_quote.map(str::to_string), + column_name: column_name.to_string(), + mode: DataGridContextFilterMode::Equals, + value: json!(1), + values: Vec::new(), + end_value: None, + column_info: Some(column(column_name, "integer", false, None)), + }) + .as_deref(), + Some(expected) + ); + } assert_eq!( build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions { database_type: Some(DatabaseType::Kingbase), @@ -3829,6 +3859,30 @@ mod tests { }), "SELECT COUNT(*) AS cnt FROM `cqbq_ls`.`ANALYZE`" ); + assert_eq!( + build_data_grid_count_sql(DataGridCountSqlOptions { + database_type: Some(DatabaseType::Gaussdb), + identifier_quote: Some("\"".to_string()), + catalog: None, + database: None, + schema: Some("schema_01".to_string()), + table_name: "table_01".to_string(), + where_input: None, + }), + "SELECT COUNT(*) AS cnt FROM schema_01.table_01" + ); + assert_eq!( + build_data_grid_count_sql(DataGridCountSqlOptions { + database_type: Some(DatabaseType::Postgres), + identifier_quote: Some("`".to_string()), + catalog: None, + database: None, + schema: Some("App Schema".to_string()), + table_name: "order".to_string(), + where_input: None, + }), + "SELECT COUNT(*) AS cnt FROM `App Schema`.`order`" + ); } #[test] @@ -4478,6 +4532,59 @@ mod tests { .all(|statement| statement.contains("`gc`.`docfileinfo`") && !statement.contains('"'))); } + #[test] + fn gaussdb_jdbc_save_selectively_quotes_identifiers() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Gaussdb), + identifier_quote: Some("\"".to_string()), + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("schema_01".to_string()), + table_name: "table_01".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![column("id", "integer", false, None), column("name", "varchar", false, None)]), + }, + columns: vec!["id".to_string(), "name".to_string()], + source_columns: None, + rows: vec![vec![json!(1), json!("old")]], + dirty_rows: vec![(0, vec![(1, json!("new"))])], + deleted_rows: vec![], + new_rows: vec![], + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements, vec!["UPDATE schema_01.table_01 SET name = 'new' WHERE id = 1;"]); + } + + #[test] + fn postgres_driver_to_gaussdb_m_mode_save_uses_backticks() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Postgres), + identifier_quote: Some("`".to_string()), + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("App Schema".to_string()), + table_name: "order".to_string(), + primary_keys: vec!["ID".to_string()], + columns: Some(vec![ + column("ID", "integer", false, None), + column("display name", "varchar", false, None), + ]), + }, + columns: vec!["ID".to_string(), "display name".to_string()], + source_columns: None, + rows: vec![vec![json!(1), json!("old")]], + dirty_rows: vec![(0, vec![(1, json!("new"))])], + deleted_rows: vec![], + new_rows: vec![], + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.statements, vec!["UPDATE `App Schema`.`order` SET `display name` = 'new' WHERE `ID` = 1;"]); + } + #[test] fn postgres_save_uses_exact_quoted_primary_key_for_update_delete_and_rollback() { let result = prepare_data_grid_save(DataGridSaveStatementOptions { diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 378277006c..a5afd4519f 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -30,6 +30,25 @@ use crate::types::{ OwnerInfo, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo, }; +const GAUSSDB_COMPATIBILITY_SQL: &str = + "SELECT datcompatibility FROM pg_catalog.pg_database WHERE datname = current_database()"; + +pub async fn gaussdb_identifier_quote(pool: &Pool) -> Option { + let timeout = super::connection_timeout(); + let client = checkout_postgres_client(pool, None, timeout).await.ok()?; + let row = tokio::time::timeout(timeout, client.query_opt(GAUSSDB_COMPATIBILITY_SQL, &[])).await.ok()?.ok()??; + let compatibility_mode = row.try_get::<_, String>(0).ok()?; + gaussdb_identifier_quote_for_compatibility_mode(&compatibility_mode).map(str::to_string) +} + +fn gaussdb_identifier_quote_for_compatibility_mode(compatibility_mode: &str) -> Option<&'static str> { + match compatibility_mode.trim().to_ascii_uppercase().as_str() { + "M" | "B" | "MYSQL" => Some("`"), + "A" | "PG" | "ORA" | "POSTGRESQL" => Some("\""), + _ => None, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PostgresTablePrivilegeInfo { pub grantor: String, @@ -4045,6 +4064,18 @@ mod tests { use std::time::Instant; use tokio_postgres::types::FromSql; + #[test] + fn gaussdb_compatibility_mode_selects_identifier_quote() { + for mode in ["M", "B", "mysql", " MYSQL "] { + assert_eq!(gaussdb_identifier_quote_for_compatibility_mode(mode), Some("`")); + } + for mode in ["A", "PG", "ora", " PostgreSQL "] { + assert_eq!(gaussdb_identifier_quote_for_compatibility_mode(mode), Some("\"")); + } + assert_eq!(gaussdb_identifier_quote_for_compatibility_mode("C"), None); + assert_eq!(gaussdb_identifier_quote_for_compatibility_mode(""), None); + } + fn pg_interval_bytes(microseconds: i64, days: i32, months: i32) -> [u8; 16] { let mut raw = [0_u8; 16]; raw[0..8].copy_from_slice(µseconds.to_be_bytes()); diff --git a/crates/dbx-core/src/sql_dialect.rs b/crates/dbx-core/src/sql_dialect.rs index d889b695c4..d80c1202db 100644 --- a/crates/dbx-core/src/sql_dialect.rs +++ b/crates/dbx-core/src/sql_dialect.rs @@ -39,7 +39,9 @@ pub use identifiers::{ }; pub(crate) use identifiers::{parse_sqlserver_linked_schema_ref, qualified_transfer_table, quote_transfer_identifier}; pub use table_select::{build_count_table_sql, build_table_data_select_sql, build_table_select_sql}; -pub(crate) use table_select::{quote_table_data_identifier, table_data_qualified_table_name}; +pub(crate) use table_select::{ + quote_table_data_identifier, table_data_qualified_table_name, uses_connection_identifier_quote, +}; pub use type_rewrite::{ apply_auto_inc_to_column_def, column_is_auto_increment, rewrite_column_type, split_type_base_params, type_looks_integer, AutoIncColumnBuild, diff --git a/crates/dbx-core/src/sql_dialect/identifiers.rs b/crates/dbx-core/src/sql_dialect/identifiers.rs index ad80e74b1d..f919b115da 100644 --- a/crates/dbx-core/src/sql_dialect/identifiers.rs +++ b/crates/dbx-core/src/sql_dialect/identifiers.rs @@ -111,6 +111,11 @@ pub fn qualified_table_name_with_catalog( } pub fn quote_table_identifier(database_type: Option, name: &str) -> String { + if matches!(database_type, Some(DatabaseType::Gaussdb | DatabaseType::OpenGauss)) + && is_explicitly_quoted_identifier(name) + { + return name.to_string(); + } match database_type { Some(DatabaseType::Iotdb) => name.to_string(), // JDBC connections use the driver-reported identifier quote string @@ -141,6 +146,221 @@ pub fn quote_table_identifier(database_type: Option, name: &str) - } } +pub(crate) fn quote_gaussdb_jdbc_identifier(name: &str, identifier_quote: &str) -> String { + if is_explicitly_quoted_identifier(name) { + return name.to_string(); + } + let quote = identifier_quote.trim(); + if quote.is_empty() { + return name.to_string(); + } + let requires_quote = !is_simple_lower_identifier(name) + || is_postgres_reserved_identifier(name) + || (quote == "`" && is_mysql_only_reserved_identifier(name)); + if !requires_quote { + return name.to_string(); + } + format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}"))) +} + +fn is_explicitly_quoted_identifier(name: &str) -> bool { + name.len() >= 2 + && ((name.starts_with('"') && name.ends_with('"')) + || (name.starts_with('`') && name.ends_with('`')) + || (name.starts_with('[') && name.ends_with(']'))) +} + +fn is_simple_lower_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_lowercase()) + && chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_lowercase() || ch.is_ascii_digit()) +} + +fn is_postgres_reserved_identifier(name: &str) -> bool { + matches!( + name, + "all" + | "analyse" + | "analyze" + | "and" + | "any" + | "array" + | "as" + | "asc" + | "asymmetric" + | "authorization" + | "binary" + | "both" + | "case" + | "cast" + | "check" + | "collate" + | "collation" + | "column" + | "concurrently" + | "constraint" + | "create" + | "cross" + | "current_catalog" + | "current_date" + | "current_role" + | "current_schema" + | "current_time" + | "current_timestamp" + | "current_user" + | "default" + | "deferrable" + | "desc" + | "distinct" + | "do" + | "else" + | "end" + | "except" + | "false" + | "fetch" + | "for" + | "foreign" + | "freeze" + | "from" + | "full" + | "grant" + | "group" + | "having" + | "ilike" + | "in" + | "initially" + | "inner" + | "intersect" + | "into" + | "is" + | "isnull" + | "join" + | "lateral" + | "leading" + | "left" + | "like" + | "limit" + | "localtime" + | "localtimestamp" + | "natural" + | "not" + | "notnull" + | "null" + | "offset" + | "on" + | "only" + | "or" + | "order" + | "outer" + | "overlaps" + | "placing" + | "primary" + | "references" + | "returning" + | "right" + | "select" + | "session_user" + | "similar" + | "some" + | "symmetric" + | "system_user" + | "table" + | "tablesample" + | "then" + | "to" + | "trailing" + | "true" + | "union" + | "unique" + | "user" + | "using" + | "variadic" + | "verbose" + | "when" + | "where" + | "window" + | "with" + ) +} + +fn is_mysql_only_reserved_identifier(name: &str) -> bool { + matches!( + name, + "accessible" + | "auto_increment" + | "change" + | "database" + | "databases" + | "delayed" + | "describe" + | "div" + | "dual" + | "enclosed" + | "escaped" + | "explain" + | "force" + | "fulltext" + | "high_priority" + | "ignore" + | "index" + | "infile" + | "key" + | "keys" + | "kill" + | "linear" + | "lines" + | "load" + | "lock" + | "low_priority" + | "master_ssl_verify_server_cert" + | "maxvalue" + | "mediumint" + | "mod" + | "no_write_to_binlog" + | "optimize" + | "optionally" + | "outfile" + | "partition" + | "purge" + | "range" + | "read_write" + | "regexp" + | "release" + | "rename" + | "replace" + | "require" + | "rlike" + | "schema" + | "schemas" + | "separator" + | "show" + | "spatial" + | "sql_big_result" + | "sql_calc_found_rows" + | "sql_small_result" + | "ssl" + | "starting" + | "straight_join" + | "terminated" + | "tinyint" + | "unlock" + | "unsigned" + | "use" + | "utc_date" + | "utc_time" + | "utc_timestamp" + | "values" + | "varbinary" + | "varchar" + | "write" + | "xor" + | "zerofill" + ) +} + pub fn normalize_where_input(where_input: Option<&str>) -> String { let trimmed = where_input.unwrap_or("").trim().trim_end_matches(';').trim(); let mut chars = trimmed.chars(); diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index e42eb1b98e..accea8c578 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -4,7 +4,8 @@ use super::capabilities::{ firebird_rows_clause, table_pagination_strategy, uses_oracle_row_id, TablePaginationStrategy, }; use super::identifiers::{ - normalize_where_input, qualified_table_name, qualified_table_name_with_catalog, quote_table_identifier, + normalize_where_input, qualified_table_name, qualified_table_name_with_catalog, quote_gaussdb_jdbc_identifier, + quote_table_identifier, }; use super::types::{ TableDataSelectSqlOptions, TableSelectSqlOptions, DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, @@ -23,7 +24,7 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String } // Doris / StarRocks multi-catalog: prefix the catalog for external-catalog tables. - let table = if database_type == Some(DatabaseType::Kingbase) { + let table = if uses_connection_identifier_quote(database_type, options.identifier_quote.as_deref()) { table_data_qualified_table_name( database_type, options.schema.as_deref(), @@ -166,7 +167,7 @@ pub(crate) fn table_data_qualified_table_name( table_name: &str, identifier_quote: Option<&str>, ) -> String { - if database_type != Some(DatabaseType::Kingbase) { + if !uses_connection_identifier_quote(database_type, identifier_quote) { return qualified_table_name(database_type, schema, table_name); } let table = quote_table_data_identifier(database_type, table_name, identifier_quote); @@ -182,18 +183,30 @@ pub(crate) fn quote_table_data_identifier( name: &str, identifier_quote: Option<&str>, ) -> String { - if database_type != Some(DatabaseType::Kingbase) { + if !uses_connection_identifier_quote(database_type, identifier_quote) { return quote_table_identifier(database_type, name); } let Some(quote) = identifier_quote else { return quote_table_identifier(database_type, name); }; + if matches!(database_type, Some(DatabaseType::Gaussdb | DatabaseType::OpenGauss | DatabaseType::Postgres)) { + return quote_gaussdb_jdbc_identifier(name, quote); + } if quote.is_empty() { return name.to_string(); } format!("{quote}{}{quote}", name.replace(quote, &format!("{quote}{quote}"))) } +pub(crate) fn uses_connection_identifier_quote( + database_type: Option, + identifier_quote: Option<&str>, +) -> bool { + database_type == Some(DatabaseType::Kingbase) + || (matches!(database_type, Some(DatabaseType::Gaussdb | DatabaseType::OpenGauss | DatabaseType::Postgres)) + && identifier_quote.is_some()) +} + fn is_view_table_type(table_type: Option<&str>) -> bool { table_type.is_some_and(|value| value.to_ascii_uppercase().contains("VIEW")) } diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 4f354bf3be..b82548a39c 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -26,12 +26,39 @@ fn quotes_identifiers_by_database_type() { assert_eq!(quote_table_identifier(Some(DatabaseType::Kingbase), "order"), "\"order\""); assert_eq!(quote_table_identifier(Some(DatabaseType::Kingbase), "MixedCase"), "\"MixedCase\""); assert_eq!(quote_table_identifier(Some(DatabaseType::Kingbase), "order detail"), "\"order detail\""); + assert_eq!(quote_table_identifier(Some(DatabaseType::Gaussdb), "\"MixedCase\""), "\"MixedCase\""); + assert_eq!(quote_table_identifier(Some(DatabaseType::OpenGauss), "\"MixedCase\""), "\"MixedCase\""); assert_eq!(quote_table_identifier(Some(DatabaseType::Informix), "users_1"), "users_1"); assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "users_1"), "users_1"); assert_eq!(quote_table_identifier(Some(DatabaseType::Jdbc), "user name"), "user name"); assert_eq!(quote_table_identifier(Some(DatabaseType::Iotdb), "root.test.device2"), "root.test.device2"); } +#[test] +fn quotes_gaussdb_jdbc_identifiers_selectively() { + for (name, expected) in [ + ("schema_01", "schema_01"), + ("MixedCase", "\"MixedCase\""), + ("order", "\"order\""), + ("order detail", "\"order detail\""), + ("already\"quoted", "\"already\"\"quoted\""), + ("\"AlreadyQuoted\"", "\"AlreadyQuoted\""), + ] { + assert_eq!(quote_table_data_identifier(Some(DatabaseType::Gaussdb), name, Some("\"")), expected); + } + + for (name, expected) in [ + ("schema_01", "schema_01"), + ("MixedCase", "`MixedCase`"), + ("order", "`order`"), + ("order detail", "`order detail`"), + ("already`quoted", "`already``quoted`"), + ("`AlreadyQuoted`", "`AlreadyQuoted`"), + ] { + assert_eq!(quote_table_data_identifier(Some(DatabaseType::Gaussdb), name, Some("`")), expected); + } +} + #[test] fn qualifies_schema_only_for_schema_aware_databases() { assert_eq!(qualified_table_name(Some(DatabaseType::Postgres), Some("public"), "users"), "\"public\".\"users\""); @@ -342,6 +369,72 @@ fn builds_table_data_where_and_schema_queries() { }), "SELECT * FROM \"App Schema\".\"ANALYZE\" LIMIT 100;" ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Gaussdb), + identifier_quote: Some("\"".to_string()), + schema: Some("schema_01".to_string()), + table_name: "table_01".to_string(), + limit: Some(100), + ..Default::default() + }), + "SELECT * FROM schema_01.table_01 LIMIT 100;" + ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Gaussdb), + identifier_quote: Some("`".to_string()), + schema: Some("App Schema".to_string()), + table_name: "order".to_string(), + limit: Some(100), + ..Default::default() + }), + "SELECT * FROM `App Schema`.`order` LIMIT 100;" + ); + for database_type in [DatabaseType::Postgres, DatabaseType::OpenGauss] { + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(database_type), + identifier_quote: Some("`".to_string()), + schema: Some("App Schema".to_string()), + table_name: "order".to_string(), + limit: Some(100), + ..Default::default() + }), + "SELECT * FROM `App Schema`.`order` LIMIT 100;" + ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(database_type), + identifier_quote: Some("\"".to_string()), + schema: Some("schema_01".to_string()), + table_name: "MixedCase".to_string(), + limit: Some(100), + ..Default::default() + }), + "SELECT * FROM schema_01.\"MixedCase\" LIMIT 100;" + ); + } + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Gaussdb), + schema: Some("schema_01".to_string()), + table_name: "table_01".to_string(), + limit: Some(100), + ..Default::default() + }), + "SELECT * FROM \"schema_01\".\"table_01\" LIMIT 100;" + ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::OpenGauss), + schema: Some("schema_01".to_string()), + table_name: "table_01".to_string(), + limit: Some(100), + ..Default::default() + }), + "SELECT * FROM \"schema_01\".\"table_01\" LIMIT 100;" + ); assert_eq!( build_table_data_select_sql(TableDataSelectSqlOptions { database_type: Some(DatabaseType::Xugu), diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index d1b17e2a53..555234fffd 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -509,6 +509,7 @@ async fn live_sqlserver_bulk_imports_zero_fraction_xlsx_numbers_into_bigint() { sheet_name: Some("Numbers".to_string()), columns: vec!["id".to_string(), "label".to_string()], column_types: Vec::new(), + column_comments: Vec::new(), rows: vec![vec![serde_json::json!(1.0), serde_json::json!("xlsx")]], numeric_column_right_align: false, }) diff --git a/packages/app-tests/jdbcDialect.test.ts b/packages/app-tests/jdbcDialect.test.ts index a114f0c380..e2bb7bf0f4 100644 --- a/packages/app-tests/jdbcDialect.test.ts +++ b/packages/app-tests/jdbcDialect.test.ts @@ -1,6 +1,18 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { codeMirrorSqlDialectForConnection, connectionShouldDiscoverJdbcSchemas, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, inferJdbcDialect, sqlSnippetDatabaseTypeForConnection } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; +import { + codeMirrorSqlDialectForConnection, + connectionShouldDiscoverJdbcSchemas, + connectionShouldLoadIdentifierQuote, + connectionUsesDatabaseObjectTreeMode, + effectiveDatabaseTypeForConnection, + gaussdbIdentifierQuoteOverride, + gaussdbIdentifierQuoteStyle, + inferJdbcDialect, + setGaussdbIdentifierQuoteStyle, + sqlSnippetDatabaseTypeForConnection, + supportsGaussdbIdentifierQuoteStyle, +} from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; test("infers GoldenDB for generic JDBC connections", () => { assert.equal( @@ -57,6 +69,42 @@ test("infers GaussDB-compatible JDBC connections as schema-aware", () => { assert.equal(connectionUsesDatabaseObjectTreeMode(opengaussConnection), false); }); +test("loads driver-reported identifier quotes for compatible JDBC connections", () => { + assert.equal(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_paths: ["/drivers/gaussdb.jar"] }), true); + assert.equal(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_class: "org.opengauss.Driver" }), true); + assert.equal(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_class: "org.postgresql.Driver" }), true); + assert.equal(connectionShouldLoadIdentifierQuote({ db_type: "kingbase" }), true); + assert.equal(connectionShouldLoadIdentifierQuote({ db_type: "gaussdb" }), true); +}); + +test("GaussDB server metadata overrides PostgreSQL-compatible JDBC driver identity", () => { + const connection = { + db_type: "jdbc" as const, + connection_string: "jdbc:postgresql://localhost:5432/postgres", + jdbc_driver_class: "org.postgresql.Driver", + database_info: { productName: "GaussDB Kernel" }, + }; + + assert.equal(inferJdbcDialect(connection), "gaussdb"); + assert.equal(effectiveDatabaseTypeForConnection(connection), "gaussdb"); + assert.equal(connectionShouldLoadIdentifierQuote(connection), true); +}); + +test("persists and resolves GaussDB identifier quote overrides", () => { + const config = { + db_type: "jdbc" as const, + jdbc_driver_paths: ["/drivers/gaussdb.jar"], + external_config: { retained: true } as unknown, + }; + + assert.equal(supportsGaussdbIdentifierQuoteStyle(config), true); + assert.equal(gaussdbIdentifierQuoteStyle(config), "auto"); + setGaussdbIdentifierQuoteStyle(config, "backtick"); + assert.deepEqual(config.external_config, { retained: true, gaussdbIdentifierQuoteStyle: "backtick" }); + assert.equal(gaussdbIdentifierQuoteOverride(config), "`"); + assert.equal(connectionShouldLoadIdentifierQuote(config), false); +}); + test("infers Dameng JDBC connections", () => { const damengConnection = { db_type: "jdbc" as const,