diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java index 88c09eef..7c0d4764 100644 --- a/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java @@ -29,11 +29,26 @@ public class ConnectionManager { public void connect(String connId, String url, String username, String password, String driverClass, List driverJars, - int minPool, int maxPool) throws ClassifiedException, Exception { + int minPool, int maxPool, + boolean credentialsInUrl, + String sslMode, String sslCaCert, + String sslClientCert, String sslClientKey, + boolean trustServerCertificate) throws ClassifiedException, Exception { if (pools.containsKey(connId)) { throw new Exception("Connection already exists: " + connId); } + if (credentialsInUrl) { + StringBuilder sb = new StringBuilder(url); + sb.append(url.contains("?") ? "&" : "?"); + sb.append("user=").append(username); + if (password != null && !password.isEmpty()) { + sb.append("&password=").append(password); + } + url = sb.toString(); + } + final String jdbcUrl = url; + DriverClassLoader loader = new DriverClassLoader(driverJars); Class driverCls = Class.forName(driverClass, true, loader); if (!java.sql.Driver.class.isAssignableFrom(driverCls)) { @@ -48,13 +63,15 @@ public java.sql.Connection getConnection() throws java.sql.SQLException { java.util.Properties info = new java.util.Properties(); if (username != null) info.setProperty("user", username); if (password != null) info.setProperty("password", password); - return driver.connect(url, info); + SslPropertyMapper.applySslProperties(driverClass, jdbcUrl, sslMode, sslCaCert, sslClientCert, sslClientKey, trustServerCertificate, info); + return driver.connect(jdbcUrl, info); } public java.sql.Connection getConnection(String u, String p) throws java.sql.SQLException { java.util.Properties info = new java.util.Properties(); - info.setProperty("user", u); - info.setProperty("password", p); - return driver.connect(url, info); + if (u != null) info.setProperty("user", u); + if (p != null) info.setProperty("password", p); + SslPropertyMapper.applySslProperties(driverClass, jdbcUrl, sslMode, sslCaCert, sslClientCert, sslClientKey, trustServerCertificate, info); + return driver.connect(jdbcUrl, info); } public java.io.PrintWriter getLogWriter() { return null; } public void setLogWriter(java.io.PrintWriter out) {} diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/DriverResolver.java b/jdbc-bridge/src/main/java/sqlkit/bridge/DriverResolver.java index 65eb8ba5..dacd9e08 100644 --- a/jdbc-bridge/src/main/java/sqlkit/bridge/DriverResolver.java +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/DriverResolver.java @@ -40,10 +40,43 @@ private static String getDriversCacheDir() { * @param mavenArtifact e.g. "h2" * @param versionCap Optional max version to cap against. Null means resolve LATEST. * @param classifier Optional Maven classifier (e.g. "standalone"). Null means no classifier. + * @param downloadUrl Direct download URL for drivers NOT on Maven Central. Null means use Maven. * @return DriverResult with path to the cached JAR and resolved version */ - public static DriverResult resolve(String mavenGroup, String mavenArtifact, - String versionCap, String classifier) throws Exception { + public static DriverResult resolve(String mavenGroup, String mavenArtifact, + String versionCap, String classifier, + String downloadUrl) throws Exception { + if (downloadUrl != null && !downloadUrl.isEmpty()) { + return resolveDirect(mavenArtifact, versionCap, downloadUrl); + } + return resolveFromMaven(mavenGroup, mavenArtifact, versionCap, classifier); + } + + private static DriverResult resolveDirect(String mavenArtifact, String versionCap, String downloadUrl) throws Exception { + String version = (versionCap != null && !versionCap.isEmpty()) ? versionCap : "1.0.0"; + String jarFilename = mavenArtifact + "-" + version + ".jar"; + Path destPath = Paths.get(DRIVERS_CACHE, mavenArtifact, jarFilename); + + if (!Files.exists(destPath)) { + Files.createDirectories(destPath.getParent()); + Request request = new Request.Builder() + .url(downloadUrl) + .addHeader("User-Agent", "SQLKit/1.0") + .build(); + Response response = HTTP_CLIENT.newCall(request).execute(); + if (!response.isSuccessful()) { + throw new Exception("Failed to download JAR: HTTP " + response.code() + " for " + downloadUrl); + } + byte[] jarBytes = response.body() != null ? response.body().bytes() : new byte[0]; + response.close(); + Files.write(destPath, jarBytes); + } + + return new DriverResult(destPath.toAbsolutePath().toString(), version); + } + + private static DriverResult resolveFromMaven(String mavenGroup, String mavenArtifact, + String versionCap, String classifier) throws Exception { // 1. Fetch maven-metadata.xml String metadataUrl = String.format("%s/%s/%s/maven-metadata.xml", MAVEN_CENTRAL, mavenGroup.replace('.', '/'), mavenArtifact); diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java b/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java index c202c2a8..b5ae6301 100644 --- a/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java @@ -121,6 +121,8 @@ private void handleConnect(JsonNode params, ObjectNode response) throws Exceptio String driverClass = requiredString(params, "driver_class", null); int poolMin = params.has("pool_min") ? params.get("pool_min").asInt(1) : 1; int poolMax = params.has("pool_max") ? params.get("pool_max").asInt(5) : 5; + boolean credentialsInUrl = params.has("credentials_in_url") && !params.get("credentials_in_url").isNull() + && params.get("credentials_in_url").asBoolean(false); List driverJars = new ArrayList<>(); if (params.has("driver_jars") && params.get("driver_jars").isArray()) { @@ -129,10 +131,13 @@ private void handleConnect(JsonNode params, ObjectNode response) throws Exceptio } } - // Extract Oracle-specific connection options - String tnsAdminDir = null; - String walletPassword = null; - connectionManager.connect(connId, url, username, password, driverClass, driverJars, poolMin, poolMax); + String sslMode = params.has("ssl_mode") && !params.get("ssl_mode").isNull() ? params.get("ssl_mode").asText() : null; + String sslCaCert = params.has("ssl_ca_cert") && !params.get("ssl_ca_cert").isNull() ? params.get("ssl_ca_cert").asText() : null; + String sslClientCert = params.has("ssl_client_cert") && !params.get("ssl_client_cert").isNull() ? params.get("ssl_client_cert").asText() : null; + String sslClientKey = params.has("ssl_client_key") && !params.get("ssl_client_key").isNull() ? params.get("ssl_client_key").asText() : null; + boolean trustServerCertificate = params.has("trust_server_certificate") && params.get("trust_server_certificate").asBoolean(false); + + connectionManager.connect(connId, url, username, password, driverClass, driverJars, poolMin, poolMax, credentialsInUrl, sslMode, sslCaCert, sslClientCert, sslClientKey, trustServerCertificate); response.put("result", connId); } @@ -214,8 +219,10 @@ private void handleResolveDriver(JsonNode params, ObjectNode response) throws Ex ? params.get("version_cap").asText() : null; String classifier = params.has("maven_classifier") && !params.get("maven_classifier").isNull() ? params.get("maven_classifier").asText() : null; - - DriverResolver.DriverResult result = DriverResolver.resolve(mavenGroup, mavenArtifact, versionCap, classifier); + String downloadUrl = params.has("download_url") && !params.get("download_url").isNull() + ? params.get("download_url").asText() : null; + + DriverResolver.DriverResult result = DriverResolver.resolve(mavenGroup, mavenArtifact, versionCap, classifier, downloadUrl); ObjectNode resultNode = MAPPER.createObjectNode(); resultNode.put("jar_path", result.getJarPath()); diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/SslPropertyMapper.java b/jdbc-bridge/src/main/java/sqlkit/bridge/SslPropertyMapper.java new file mode 100644 index 00000000..bdc7861b --- /dev/null +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/SslPropertyMapper.java @@ -0,0 +1,356 @@ +package sqlkit.bridge; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Properties; + +public class SslPropertyMapper { + private static final String KEYSTORE_PASSWORD = "changeit"; + + public static void applySslProperties(String driverClass, String jdbcUrl, + String sslMode, String sslCaCert, String sslClientCert, + String sslClientKey, boolean trustServerCertificate, + Properties props) { + if (sslMode == null || sslMode.equals("disable")) return; + + switch (driverClass) { + case "org.postgresql.Driver": + props.setProperty("sslmode", mapToPostgresSslMode(sslMode)); + if (sslCaCert != null) props.setProperty("sslrootcert", sslCaCert); + if (sslClientCert != null) props.setProperty("sslcert", sslClientCert); + if (sslClientKey != null) props.setProperty("sslkey", sslClientKey); + break; + case "com.mysql.cj.jdbc.Driver": + case "com.mysql.jdbc.Driver": + props.setProperty("sslMode", mapToMysqlSslMode(sslMode)); + if (sslCaCert != null) { + String p12 = convertPemCaCertToPkcs12(sslCaCert); + if (p12 != null) { + props.setProperty("trustCertificateKeyStoreUrl", "file:" + p12); + props.setProperty("trustCertificateKeyStoreType", "PKCS12"); + props.setProperty("trustCertificateKeyStorePassword", KEYSTORE_PASSWORD); + } + } + if (sslClientCert != null && sslClientKey != null) { + String p12 = convertPemClientCertToPkcs12(sslClientCert, sslClientKey); + if (p12 != null) { + props.setProperty("clientCertificateKeyStoreUrl", "file:" + p12); + props.setProperty("clientCertificateKeyStoreType", "PKCS12"); + props.setProperty("clientCertificateKeyStorePassword", KEYSTORE_PASSWORD); + } + } + break; + case "com.microsoft.sqlserver.jdbc.SQLServerDriver": + props.setProperty("encrypt", "true"); + props.setProperty("trustServerCertificate", + String.valueOf(trustServerCertificate || sslMode.equals("prefer") || sslMode.equals("require"))); + if (sslCaCert != null && (sslMode.equals("verify-ca") || sslMode.equals("verify-full"))) { + String p12 = convertPemCaCertToPkcs12(sslCaCert); + if (p12 != null) { + props.setProperty("trustStore", p12); + props.setProperty("trustStoreType", "PKCS12"); + props.setProperty("trustStorePassword", KEYSTORE_PASSWORD); + } + } + break; + case "oracle.jdbc.OracleDriver": + props.setProperty("oracle.net.ssl", "true"); + if (sslCaCert != null) { + String p12 = convertPemCaCertToPkcs12(sslCaCert); + if (p12 != null) { + props.setProperty("oracle.net.ssl_truststore", p12); + props.setProperty("oracle.net.ssl_truststore_type", "PKCS12"); + props.setProperty("oracle.net.ssl_truststore_password", KEYSTORE_PASSWORD); + } + } + if (sslClientCert != null && sslClientKey != null) { + String p12 = convertPemClientCertToPkcs12(sslClientCert, sslClientKey); + if (p12 != null) { + props.setProperty("oracle.net.ssl_keystore", p12); + props.setProperty("oracle.net.ssl_keystore_type", "PKCS12"); + props.setProperty("oracle.net.ssl_keystore_password", KEYSTORE_PASSWORD); + } + } + props.setProperty("oracle.net.ssl_server_dn_match", + sslMode.equals("verify-full") ? "true" : "false"); + break; + case "net.snowflake.client.jdbc.SnowflakeDriver": + props.setProperty("ssl", "on"); + break; + case "com.ibm.db2.jcc.DB2Driver": + props.setProperty("sslConnection", "true"); + if (sslCaCert != null) { + String p12 = convertPemCaCertToPkcs12(sslCaCert); + if (p12 != null) { + props.setProperty("sslTrustStoreLocation", p12); + props.setProperty("sslTrustStorePassword", KEYSTORE_PASSWORD); + } + } + break; + case "org.apache.hive.jdbc.HiveDriver": + props.setProperty("ssl", "true"); + if (sslCaCert != null) { + String p12 = convertPemCaCertToPkcs12(sslCaCert); + if (p12 != null) { + props.setProperty("sslTrustStore", p12); + props.setProperty("sslTrustStorePassword", KEYSTORE_PASSWORD); + } + } + break; + case "com.teradata.jdbc.TeraDriver": + props.setProperty("TLS", "on"); + break; + case "com.sap.db.jdbc.Driver": + props.setProperty("encrypt", "true"); + break; + case "com.vertica.jdbc.Driver": + props.setProperty("ssl", "true"); + if (sslMode.equals("verify-full")) props.setProperty("ssl_hostname_verify", "true"); + break; + default: + props.setProperty("ssl", "true"); + break; + } + } + + private static String convertPemCaCertToPkcs12(String pemPath) { + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + java.util.Collection caCerts; + try (InputStream is = new FileInputStream(pemPath)) { + caCerts = cf.generateCertificates(is); + } + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + int i = 0; + for (Certificate cert : caCerts) { + ks.setCertificateEntry("ca-cert-" + i, cert); + i++; + } + Path tempFile = Files.createTempFile("sqlkit-ca-", ".p12"); + try (OutputStream os = Files.newOutputStream(tempFile)) { + ks.store(os, KEYSTORE_PASSWORD.toCharArray()); + } + tempFile.toFile().deleteOnExit(); + return tempFile.toAbsolutePath().toString(); + } catch (Exception e) { + System.err.println("Failed to convert CA cert to PKCS12: " + e.getMessage()); + return null; + } + } + + private static String convertPemClientCertToPkcs12(String certPath, String keyPath) { + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate clientCert; + try (InputStream is = new FileInputStream(certPath)) { + clientCert = (X509Certificate) cf.generateCertificate(is); + } + PrivateKey privateKey = loadPrivateKeyFromPem(keyPath); + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("client-key", privateKey, KEYSTORE_PASSWORD.toCharArray(), + new Certificate[]{clientCert}); + Path tempFile = Files.createTempFile("sqlkit-client-", ".p12"); + try (OutputStream os = Files.newOutputStream(tempFile)) { + ks.store(os, KEYSTORE_PASSWORD.toCharArray()); + } + tempFile.toFile().deleteOnExit(); + return tempFile.toAbsolutePath().toString(); + } catch (Exception e) { + System.err.println("Failed to convert client cert to PKCS12: " + e.getMessage()); + return null; + } + } + + private static PrivateKey loadPrivateKeyFromPem(String keyPath) throws Exception { + String content = new String(Files.readAllBytes(Path.of(keyPath))); + boolean isPkcs1 = content.contains("-----BEGIN RSA PRIVATE KEY-----"); + boolean isEc = content.contains("-----BEGIN EC PRIVATE KEY-----"); + content = content.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN EC PRIVATE KEY-----", "") + .replace("-----END EC PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] keyBytes = Base64.getDecoder().decode(content); + + if (isPkcs1) { + byte[] pkcs8Bytes = wrapRsaPkcs1ToPkcs8(keyBytes); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(pkcs8Bytes); + return java.security.KeyFactory.getInstance("RSA").generatePrivate(spec); + } + + if (isEc) { + byte[] pkcs8Bytes = wrapEcSec1ToPkcs8(keyBytes); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(pkcs8Bytes); + return java.security.KeyFactory.getInstance("EC").generatePrivate(spec); + } + + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + try { + return java.security.KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (Exception e) { + return java.security.KeyFactory.getInstance("EC").generatePrivate(spec); + } + } + + private static byte[] wrapRsaPkcs1ToPkcs8(byte[] pkcs1Bytes) { + byte[] rsaOid = {0x2a, (byte)0x86, 0x48, (byte)0x86, (byte)0xf7, 0x0d, 0x01, 0x01, 0x01}; + byte[] algId = derSequence(derOid(rsaOid), derNull()); + byte[] octetString = derOctetString(pkcs1Bytes); + return derSequence(derInteger((byte)0), algId, octetString); + } + + private static byte[] wrapEcSec1ToPkcs8(byte[] sec1Bytes) { + byte[] ecPubkeyOid = {0x2a, (byte)0x86, 0x48, (byte)0xce, 0x3d, 0x02, 0x01}; + byte[] curveOid = extractCurveOidFromSec1(sec1Bytes); + if (curveOid == null) { + int keyLen = estimateEcKeySize(sec1Bytes); + if (keyLen == 48) { + curveOid = new byte[]{0x2b, (byte)0x81, 0x04, 0x00, 0x22}; + } else if (keyLen == 66) { + curveOid = new byte[]{0x2b, (byte)0x81, 0x04, 0x00, 0x23}; + } else { + curveOid = new byte[]{0x2a, (byte)0x86, 0x48, (byte)0xce, 0x3d, 0x03, 0x01, 0x07}; + } + } + byte[] algId = derSequence(derOid(ecPubkeyOid), derOid(curveOid)); + byte[] octetString = derOctetString(sec1Bytes); + return derSequence(derInteger((byte)0), algId, octetString); + } + + private static byte[] extractCurveOidFromSec1(byte[] sec1Der) { + try { + int idx = 0; + if (sec1Der[idx] != 0x30) return null; + idx++; + idx += derLenSize(sec1Der, idx); + if (sec1Der[idx] != 0x02) return null; + idx++; + int verLen = sec1Der[idx]; + idx += 1 + verLen; + if (sec1Der[idx] != 0x04) return null; + idx++; + int pkLen = sec1Der[idx]; + if (pkLen < 0x80) { idx += 1 + pkLen; } + else { idx += 2 + ((pkLen & 0x7f) << 8 | sec1Der[idx+1] & 0xff) - (pkLen & 0x7f); } + if (idx >= sec1Der.length) return null; + if (sec1Der[idx] == (byte)0xa0) { + idx++; + int ctxLen = sec1Der[idx]; + idx++; + if (sec1Der[idx] == 0x06) { + idx++; + int oidLen = sec1Der[idx]; + idx++; + byte[] oid = new byte[oidLen]; + System.arraycopy(sec1Der, idx, oid, 0, oidLen); + return oid; + } + } + return null; + } catch (Exception e) { + return null; + } + } + + private static int estimateEcKeySize(byte[] sec1Der) { + try { + int idx = 0; + if (sec1Der[idx] != 0x30) return 32; + idx++; + idx += derLenSize(sec1Der, idx); + if (sec1Der[idx] != 0x02) return 32; + idx++; + int verLen = sec1Der[idx]; + idx += 1 + verLen; + if (sec1Der[idx] != 0x04) return 32; + idx++; + return sec1Der[idx]; + } catch (Exception e) { + return 32; + } + } + + private static int derLenSize(byte[] der, int idx) { + if ((der[idx] & 0xff) < 0x80) return 1; + return 1 + (der[idx] & 0x7f); + } + + private static byte[] derLength(int length) { + if (length < 0x80) { + return new byte[]{(byte) length}; + } else if (length < 0x100) { + return new byte[]{(byte) 0x81, (byte) length}; + } else { + return new byte[]{(byte) 0x82, (byte) (length >> 8), (byte) length}; + } + } + + private static byte[] derSequence(byte[]... elements) { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + int contentLen = 0; + for (byte[] e : elements) contentLen += e.length; + byte[] lenBytes = derLength(contentLen); + out.write(0x30); + out.write(lenBytes, 0, lenBytes.length); + for (byte[] e : elements) out.write(e, 0, e.length); + return out.toByteArray(); + } + + private static byte[] derInteger(byte value) { + return new byte[]{0x02, 0x01, value}; + } + + private static byte[] derNull() { + return new byte[]{0x05, 0x00}; + } + + private static byte[] derOid(byte[] oidBytes) { + byte[] lenBytes = derLength(oidBytes.length); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + out.write(0x06); + out.write(lenBytes, 0, lenBytes.length); + out.write(oidBytes, 0, oidBytes.length); + return out.toByteArray(); + } + + private static byte[] derOctetString(byte[] content) { + byte[] lenBytes = derLength(content.length); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + out.write(0x04); + out.write(lenBytes, 0, lenBytes.length); + out.write(content, 0, content.length); + return out.toByteArray(); + } + + private static String mapToPostgresSslMode(String sslMode) { + switch (sslMode) { + case "verify-ca": return "verify-ca"; + case "verify-full": return "verify-full"; + case "require": return "require"; + case "prefer": return "prefer"; + default: return "disable"; + } + } + + private static String mapToMysqlSslMode(String sslMode) { + switch (sslMode) { + case "verify-full": return "VERIFY_IDENTITY"; + case "verify-ca": return "VERIFY_CA"; + case "require": return "REQUIRED"; + case "prefer": return "PREFERRED"; + default: return "DISABLED"; + } + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a8fcf68b..0676f009 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1483,7 +1483,6 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "data-studio-agent" version = "0.1.1" -source = "git+https://github.com/geek-fun/data-studio-agent.git?tag=v0.1.2#bd34c5a061ea3162b2b8d351234c05ebceae57fa" dependencies = [ "async-openai", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index edfcfe86..7767e347 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -75,7 +75,9 @@ hex = "0.4" rust_xlsxwriter = "0.64" calamine = "0.22" -# Agent / LLM dependencies (data-studio-agent release) +# Agent / LLM dependencies +# data-studio-agent: local checkout for active development. +# Revert to release when ready: { git = "https://github.com/geek-fun/data-studio-agent.git", tag = "v0.1.2" } reqwest = { version = "0.12", default-features = false, features = [ "json", "rustls-tls", @@ -86,7 +88,7 @@ http = "1" log = "0.4" futures = "0.3" rand = "0.8" -data-studio-agent = { git = "https://github.com/geek-fun/data-studio-agent.git", tag = "v0.1.2" } +data-studio-agent = { path = "/Users/blankll/Documents/devs/geekfun/data-studio-agent" } # Archive extraction (JRE downloads) flate2 = "1.0" diff --git a/src-tauri/src/commands/helpers.rs b/src-tauri/src/commands/helpers.rs index 09d76d73..6a59646d 100644 --- a/src-tauri/src/commands/helpers.rs +++ b/src-tauri/src/commands/helpers.rs @@ -168,6 +168,7 @@ fn db_type_to_enum(db_type: &str) -> Result Ok(DatabaseType::MariaDB), "tidb" => Ok(DatabaseType::TiDB), "oceanbase" => Ok(DatabaseType::OceanBase), + "oceanbase-oracle" | "oceanbase_oracle" => Ok(DatabaseType::OceanbaseOracle), "tdsql" => Ok(DatabaseType::TDSQL), "polardb" => Ok(DatabaseType::PolarDB), "dameng" | "dm" | "dm8" | "dm8_oracle" | "dm8oracle" => Ok(DatabaseType::Dameng), diff --git a/src-tauri/src/database/clickhouse.rs b/src-tauri/src/database/clickhouse.rs index 07c3c680..73c1052a 100644 --- a/src-tauri/src/database/clickhouse.rs +++ b/src-tauri/src/database/clickhouse.rs @@ -7,7 +7,7 @@ use crate::database::{ adapter::DatabaseAdapter, - config::ConnectionConfig, + config::{ConnectionConfig, SslMode}, error::{DbError, DbResult}, pool::ConnectionPool, types::{ @@ -131,20 +131,56 @@ impl ClickHouseAdapter { } } - /// Build the base URL (`http://host:port`) from the configuration. + /// Build the base URL from the configuration. fn build_base_url(&self) -> String { - format!("http://{}:{}", self.config.host, self.config.port) + let scheme = if self.config.ssl_mode == SslMode::Disable { "http" } else { "https" }; + format!("{}://{}:{}", scheme, self.config.host, self.config.port) } /// Create the `reqwest::Client` used for all HTTP calls. - fn build_client() -> DbResult { - reqwest::Client::builder() + fn build_client(&self) -> DbResult { + let mut builder = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) - .user_agent("sqlkit-clickhouse-adapter/0.1") - .build() + .user_agent("sqlkit-clickhouse-adapter/0.1"); + + builder = self.apply_ssl_to_builder(builder)?; + + builder.build() .map_err(|e| DbError::Connection(format!("Failed to create HTTP client: {}", e))) } + fn apply_ssl_to_builder(&self, mut builder: reqwest::ClientBuilder) -> DbResult { + match self.config.ssl_mode { + SslMode::Disable => {} + SslMode::Prefer | SslMode::Require => { + builder = builder.danger_accept_invalid_certs(true); + } + SslMode::VerifyCA | SslMode::VerifyFull => { + if let Some(ref ca_cert) = self.config.ssl_ca_cert { + let pem = std::fs::read(ca_cert) + .map_err(|e| DbError::Connection(format!("Failed to read CA certificate: {}", e)))?; + let cert = reqwest::Certificate::from_pem(&pem) + .map_err(|e| DbError::Connection(format!("Failed to parse CA certificate: {}", e)))?; + builder = builder.add_root_certificate(cert); + } + } + } + if let (Some(ref cert_path), Some(ref key_path)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + let cert_pem = std::fs::read(cert_path) + .map_err(|e| DbError::Connection(format!("Failed to read client certificate: {}", e)))?; + let key_pem = std::fs::read(key_path) + .map_err(|e| DbError::Connection(format!("Failed to read client key: {}", e)))?; + let mut combined = cert_pem; + combined.extend_from_slice(&key_pem); + let identity = reqwest::Identity::from_pem(&combined) + .map_err(|e| DbError::Connection(format!("Failed to parse client identity: {}", e)))?; + builder = builder.identity(identity); + } + Ok(builder) + } + /// Build the HTTP headers for a request. /// /// Adds `Content-Type: text/plain` and a `Basic` authorization header when @@ -264,7 +300,7 @@ impl DatabaseAdapter for ClickHouseAdapter { type Pool = ClickHousePool; async fn connect(&mut self) -> DbResult<()> { - let client = Self::build_client()?; + let client = self.build_client()?; // Verify connectivity by sending a simple query let url = format!("{}/?default_format=JSON", self.build_base_url()); @@ -666,18 +702,36 @@ mod tests { #[test] fn test_build_base_url() { let config = - ConnectionConfig::new(DatabaseType::ClickHouse, "ch.example.com", 8123, "default"); + ConnectionConfig::new(DatabaseType::ClickHouse, "ch.example.com", 8123, "default") + .with_ssl_mode(SslMode::Disable); let adapter = ClickHouseAdapter::new(config); assert_eq!(adapter.build_base_url(), "http://ch.example.com:8123"); } #[test] fn test_build_base_url_non_default_port() { - let config = ConnectionConfig::new(DatabaseType::ClickHouse, "localhost", 8443, "default"); + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "localhost", 8443, "default") + .with_ssl_mode(SslMode::Disable); let adapter = ClickHouseAdapter::new(config); assert_eq!(adapter.build_base_url(), "http://localhost:8443"); } + #[test] + fn test_build_base_url_https() { + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "ch.example.com", 8123, "default") + .with_ssl_mode(SslMode::Prefer); + let adapter = ClickHouseAdapter::new(config); + assert_eq!(adapter.build_base_url(), "https://ch.example.com:8123"); + } + + #[test] + fn test_build_base_url_https_require() { + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "ch.example.com", 8123, "default") + .with_ssl_mode(SslMode::Require); + let adapter = ClickHouseAdapter::new(config); + assert_eq!(adapter.build_base_url(), "https://ch.example.com:8123"); + } + // ---- Headers ---- #[test] diff --git a/src-tauri/src/database/config.rs b/src-tauri/src/database/config.rs index a1584442..1e4c111f 100644 --- a/src-tauri/src/database/config.rs +++ b/src-tauri/src/database/config.rs @@ -80,6 +80,8 @@ pub enum DatabaseType { TiDB, /// OceanBase (MySQL mode) — MySQL wire protocol. OceanBase, + /// OceanBase Oracle mode — JDBC bridge (enterprise edition). + OceanbaseOracle, /// 腾讯 TDSQL — MySQL wire protocol. TDSQL, /// 阿里云 PolarDB (MySQL mode) — MySQL wire protocol. @@ -313,6 +315,23 @@ impl ConnectionConfig { self } + pub fn with_ssl_ca_cert(mut self, ca_cert: Option) -> Self { + self.ssl_ca_cert = ca_cert; + self + } + pub fn with_ssl_client_cert(mut self, client_cert: Option) -> Self { + self.ssl_client_cert = client_cert; + self + } + pub fn with_ssl_client_key(mut self, client_key: Option) -> Self { + self.ssl_client_key = client_key; + self + } + pub fn with_trust_server_certificate(mut self, trust: bool) -> Self { + self.trust_server_certificate = trust; + self + } + /// Set the pool configuration. pub fn with_pool_config(mut self, pool_config: PoolConfig) -> Self { self.pool_config = pool_config; diff --git a/src-tauri/src/database/http_sql.rs b/src-tauri/src/database/http_sql.rs index 6b1db616..9ace6de9 100644 --- a/src-tauri/src/database/http_sql.rs +++ b/src-tauri/src/database/http_sql.rs @@ -1,6 +1,6 @@ use crate::database::{ adapter::DatabaseAdapter, - config::ConnectionConfig, + config::{ConnectionConfig, SslMode}, error::{DbError, DbResult}, pool::ConnectionPool, types::{ConnectionStatus, QueryResult, QueryRow, QueryValue}, @@ -79,7 +79,8 @@ impl HttpSqlAdapter { } fn base_url(&self) -> String { - format!("http://{}:{}", self.config.host, self.config.port) + let scheme = if self.config.ssl_mode == SslMode::Disable { "http" } else { "https" }; + format!("{}://{}:{}", scheme, self.config.host, self.config.port) } } @@ -88,9 +89,39 @@ impl DatabaseAdapter for HttpSqlAdapter { type Pool = HttpSqlPool; async fn connect(&mut self) -> DbResult<()> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() + let mut builder = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)); + + builder = match self.config.ssl_mode { + SslMode::Disable => builder, + SslMode::Prefer | SslMode::Require => builder.danger_accept_invalid_certs(true), + SslMode::VerifyCA | SslMode::VerifyFull => { + if let Some(ref ca_cert) = self.config.ssl_ca_cert { + let pem = std::fs::read(ca_cert) + .map_err(|e| DbError::Connection(format!("Failed to read CA certificate: {}", e)))?; + let cert = reqwest::Certificate::from_pem(&pem) + .map_err(|e| DbError::Connection(format!("Failed to parse CA certificate: {}", e)))?; + builder = builder.add_root_certificate(cert); + } + builder + } + }; + + if let (Some(ref cert_path), Some(ref key_path)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + let cert_pem = std::fs::read(cert_path) + .map_err(|e| DbError::Connection(format!("Failed to read client certificate: {}", e)))?; + let key_pem = std::fs::read(key_path) + .map_err(|e| DbError::Connection(format!("Failed to read client key: {}", e)))?; + let mut combined = cert_pem; + combined.extend_from_slice(&key_pem); + let identity = reqwest::Identity::from_pem(&combined) + .map_err(|e| DbError::Connection(format!("Failed to parse client identity: {}", e)))?; + builder = builder.identity(identity); + } + + let client = builder.build() .map_err(|e| DbError::Connection(e.to_string()))?; let resp = client diff --git a/src-tauri/src/database/jdbc_bridge/adapter.rs b/src-tauri/src/database/jdbc_bridge/adapter.rs index 4a456f44..c01eab6b 100644 --- a/src-tauri/src/database/jdbc_bridge/adapter.rs +++ b/src-tauri/src/database/jdbc_bridge/adapter.rs @@ -43,6 +43,13 @@ impl JdbcBridgeAdapter { /// Uses the fallback chain to try multiple driver versions automatically. async fn init_bridge(&mut self) -> DbResult>> { let db_type = self.config.db_type; + let ssl_mode_str = match self.config.ssl_mode { + crate::database::config::SslMode::Disable => "disable", + crate::database::config::SslMode::Prefer => "prefer", + crate::database::config::SslMode::Require => "require", + crate::database::config::SslMode::VerifyCA => "verify-ca", + crate::database::config::SslMode::VerifyFull => "verify-full", + }; // Use fallback chain for JDBC-dependent databases (Oracle, DB2, H2, etc.) // For non-registry types, fall back to the old single-driver approach @@ -54,6 +61,11 @@ impl JdbcBridgeAdapter { &self.config.username, &self.config.password, self.config.oracle_options.as_ref(), + Some(ssl_mode_str), + self.config.ssl_ca_cert.as_deref(), + self.config.ssl_client_cert.as_deref(), + self.config.ssl_client_key.as_deref(), + self.config.trust_server_certificate, ) .await?; diff --git a/src-tauri/src/database/jdbc_bridge/drivers.toml b/src-tauri/src/database/jdbc_bridge/drivers.toml index ddb84795..8634d0f8 100644 --- a/src-tauri/src/database/jdbc_bridge/drivers.toml +++ b/src-tauri/src/database/jdbc_bridge/drivers.toml @@ -162,12 +162,13 @@ version_error_signatures = [ [databases.xugudb] name = "虚谷 XuguDB" -class_name = "com.xugudb.jdbc.Driver" +class_name = "com.xugu.cloudjdbc.Driver" maven_group = "com.xugudb" -maven_artifact = "xugudb-jdbc" -jdbc_url_template = "jdbc:xugudb://{host}:{port}/{database}" +maven_artifact = "xugu-jdbc" +jdbc_url_template = "jdbc:xugu://{host}:{port}/{database}" default_port = 5138 min_jre_version = "11" +credentials_in_url = true version_error_signatures = [ "driver version not compatible", ] # ── 南大通用 GBase 8a ── @@ -180,6 +181,8 @@ maven_artifact = "gbase-connector-java" jdbc_url_template = "jdbc:gbase://{host}:{port}/{database}" default_port = 5258 min_jre_version = "11" +version_cap = "8.3.81.53" +download_url = "https://www.gbase8.cn/wp-content/uploads/2020/10/gbase-connector-java-8.3.81.53-build55.5.7-bin_min_mix.jar" version_error_signatures = [ "driver version not compatible", ] # ── Apache Hive ── @@ -332,3 +335,51 @@ jdbc_url_template = "jdbc:ucanaccess://{host}/{database}" default_port = 0 min_jre_version = "11" version_error_signatures = [ "driver version not compatible", ] + +# ── YashanDB (崖山数据库) ── +# +# Official JDBC driver from Yashan Technologies. Published on Maven Central. +# Uses `jdbc:yasdb:` protocol — NOT PG-wire compatible. + +[databases.yashandb] +name = "YashanDB (崖山数据库)" +class_name = "com.yashandb.jdbc.Driver" +maven_group = "com.yashandb" +maven_artifact = "yashandb-jdbc" +jdbc_url_template = "jdbc:yasdb://{host}:{port}/{database}" +default_port = 1688 +min_jre_version = "11" +version_error_signatures = [ "driver version not compatible", ] + +# ── KingbaseES (人大金仓) ── +# +# Official JDBC driver from 电科金仓. Published on Maven Central. +# Uses `jdbc:kingbase8:` protocol — PG-wire compatible but JDBC bridge +# gives access to advanced features (read-write splitting, Oracle-compat mode). + +[databases.kingbase] +name = "KingbaseES (人大金仓)" +class_name = "com.kingbase8.Driver" +maven_group = "cn.com.kingbase" +maven_artifact = "kingbase8" +jdbc_url_template = "jdbc:kingbase8://{host}:{port}/{database}" +default_port = 54321 +min_jre_version = "11" +version_error_signatures = [ "driver version not compatible", ] + +# ── OceanBase Oracle ── + +[databases.oceanbase_oracle] +name = "OceanBase Oracle" +class_name = "com.oceanbase.jdbc.Driver" +maven_group = "com.oceanbase" +maven_artifact = "oceanbase-client" +jdbc_url_template = "jdbc:oceanbase://{host}:{port}/{database}?compatibleOjdbcVersion=8" +default_port = 2881 +min_jre_version = "11" +version_error_signatures = [ + "OB-", + "compatibleOjdbcVersion", + "unsupported protocol", + "AbstractMethodError", +] diff --git a/src-tauri/src/database/jdbc_bridge/fallback.rs b/src-tauri/src/database/jdbc_bridge/fallback.rs index c420d6be..61ed601d 100644 --- a/src-tauri/src/database/jdbc_bridge/fallback.rs +++ b/src-tauri/src/database/jdbc_bridge/fallback.rs @@ -101,6 +101,11 @@ pub async fn try_driver( password: &Option, use_cap: bool, oracle_options: Option<&OracleConnectionOptions>, + ssl_mode: Option<&str>, + ssl_ca_cert: Option<&str>, + ssl_client_cert: Option<&str>, + ssl_client_key: Option<&str>, + trust_server_certificate: bool, ) -> DriverAttempt { let url = build_oracle_url(config, host, port, database, oracle_options); @@ -123,6 +128,7 @@ pub async fn try_driver( "maven_artifact": config.maven_artifact, "version_cap": effective_cap, "maven_classifier": config.maven_classifier, + "download_url": config.download_url, }); let resolve_req = JdbcRequest::new(JdbcMethod::ResolveDriver, resolve_params); @@ -182,6 +188,12 @@ pub async fn try_driver( pool_min: 1, pool_max: 5, oracle_options: oracle_options.cloned(), + credentials_in_url: config.credentials_in_url, + ssl_mode: ssl_mode.map(|s| s.to_string()), + ssl_ca_cert: ssl_ca_cert.map(|s| s.to_string()), + ssl_client_cert: ssl_client_cert.map(|s| s.to_string()), + ssl_client_key: ssl_client_key.map(|s| s.to_string()), + trust_server_certificate, }) { Ok(v) => v, Err(e) => { @@ -260,6 +272,11 @@ pub async fn run_fallback_chain( username: &str, password: &Option, oracle_options: Option<&OracleConnectionOptions>, + ssl_mode: Option<&str>, + ssl_ca_cert: Option<&str>, + ssl_client_cert: Option<&str>, + ssl_client_key: Option<&str>, + trust_server_certificate: bool, ) -> DbResult<(String, String, Arc>)> { let registry = super::registry::DriverRegistry::load(); let config = registry.get_config(db_type).ok_or_else(|| { @@ -314,14 +331,14 @@ pub async fn run_fallback_chain( // Two-phase driver resolution: // 1. Try LATEST (no version_cap) - match try_driver(config, host, port, database, username, password, false, oracle_options).await { + match try_driver(config, host, port, database, username, password, false, oracle_options, ssl_mode, ssl_ca_cert, ssl_client_cert, ssl_client_key, trust_server_certificate).await { DriverAttempt::Connected(conn_id, launcher) => { return Ok(("resolved".to_string(), conn_id, launcher)); } DriverAttempt::VersionMismatch(_) => { // 2. If LATEST fails with version_incompatible and a cap exists, retry with cap if config.version_cap.is_some() { - match try_driver(config, host, port, database, username, password, true, oracle_options).await { + match try_driver(config, host, port, database, username, password, true, oracle_options, ssl_mode, ssl_ca_cert, ssl_client_cert, ssl_client_key, trust_server_certificate).await { DriverAttempt::Connected(conn_id, launcher) => { return Ok(("capped".to_string(), conn_id, launcher)); } diff --git a/src-tauri/src/database/jdbc_bridge/protocol.rs b/src-tauri/src/database/jdbc_bridge/protocol.rs index 5275734b..ce751d01 100644 --- a/src-tauri/src/database/jdbc_bridge/protocol.rs +++ b/src-tauri/src/database/jdbc_bridge/protocol.rs @@ -78,6 +78,18 @@ pub struct ConnectParams { pub pool_max: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub oracle_options: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials_in_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_ca_cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_client_cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_client_key: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub trust_server_certificate: bool, } fn default_pool_min() -> u32 { @@ -112,6 +124,8 @@ pub struct ResolveDriverParams { pub version_cap: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub maven_classifier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/database/jdbc_bridge/registry.rs b/src-tauri/src/database/jdbc_bridge/registry.rs index 46196e12..1a694051 100644 --- a/src-tauri/src/database/jdbc_bridge/registry.rs +++ b/src-tauri/src/database/jdbc_bridge/registry.rs @@ -55,6 +55,15 @@ pub struct DatabaseDriverConfig { /// When set, the download URL becomes: {artifact}-{version}-{classifier}.jar #[serde(default)] pub maven_classifier: Option, + /// Direct download URL for drivers NOT on Maven Central (e.g. GBase 8a). + /// When present, the bridge downloads the JAR directly from this URL + /// instead of resolving via Maven metadata. + #[serde(default)] + pub download_url: Option, + /// When true, append `?user=...&password=...` to the JDBC URL instead of + /// passing credentials via Properties. Some drivers (e.g. XuguDB) require this. + #[serde(default)] + pub credentials_in_url: Option, } // --------------------------------------------------------------------------- @@ -190,6 +199,8 @@ fn db_type_to_registry_key(db: DatabaseType) -> Option<&'static str> { DatabaseType::Cassandra => Some("cassandra"), DatabaseType::Iris => Some("iris"), DatabaseType::Access => Some("access"), + DatabaseType::YashanDB => Some("yashandb"), + DatabaseType::KingbaseES => Some("kingbase"), _ => None, } } @@ -275,6 +286,8 @@ mod tests { version_error_signatures: vec![], version_cap: None, maven_classifier: None, + download_url: None, + credentials_in_url: None, }; let url = build_jdbc_url(&config, "localhost", 9092, Some("testdb")); assert_eq!(url, "jdbc:h2:tcp://localhost:9092/testdb"); @@ -294,6 +307,8 @@ mod tests { version_error_signatures: vec![], version_cap: None, maven_classifier: None, + download_url: None, + credentials_in_url: None, }; let url = build_jdbc_url(&config, "localhost", 1521, Some("XEPDB1")); assert_eq!(url, "jdbc:oracle:thin:@localhost:1521:XEPDB1"); @@ -314,6 +329,8 @@ mod tests { version_error_signatures: vec![], version_cap: None, maven_classifier: None, + download_url: None, + credentials_in_url: None, }; let url = build_jdbc_url(&config, "localhost", 9092, None); assert_eq!(url, "jdbc:h2:tcp://localhost:9092/"); @@ -334,6 +351,8 @@ mod tests { version_error_signatures: vec![], version_cap: None, maven_classifier: None, + download_url: None, + credentials_in_url: None, }; let url = build_jdbc_url(&config, "10.0.0.1", 5236, None); assert_eq!(url, "jdbc:dm://10.0.0.1:5236"); @@ -397,6 +416,8 @@ mod tests { version_error_signatures: vec![], version_cap: None, maven_classifier: None, + download_url: None, + credentials_in_url: None, }; let url = build_jdbc_url(&config, "localhost", port, None); assert_eq!( @@ -473,6 +494,14 @@ mod tests { DriverRegistry::registry_key(DatabaseType::Access), Some("access") ); + assert_eq!( + DriverRegistry::registry_key(DatabaseType::YashanDB), + Some("yashandb") + ); + assert_eq!( + DriverRegistry::registry_key(DatabaseType::KingbaseES), + Some("kingbase") + ); } #[test] @@ -490,6 +519,8 @@ mod tests { assert!(keys.contains(&"oracle"), "oracle should be in the list"); assert!(keys.contains(&"db2"), "db2 should be in the list"); assert!(keys.contains(&"h2"), "h2 should be in the list"); + assert!(keys.contains(&"yashandb"), "yashandb should be in the list"); + assert!(keys.contains(&"kingbase"), "kingbase should be in the list"); } #[test] diff --git a/src-tauri/src/database/mysql.rs b/src-tauri/src/database/mysql.rs index c7655d30..f1be1150 100644 --- a/src-tauri/src/database/mysql.rs +++ b/src-tauri/src/database/mysql.rs @@ -15,8 +15,10 @@ use crate::database::{ }; use async_trait::async_trait; use mysql_async::{ - prelude::*, Conn, OptsBuilder, Pool, PoolConstraints, PoolOpts, Row, SslOpts, Value, + prelude::*, ClientIdentity, Conn, OptsBuilder, Pool, PoolConstraints, PoolOpts, Row, + SslOpts, Value, }; +use log; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -164,10 +166,41 @@ impl MySQLAdapter { opts = opts.ssl_opts(None); } SslMode::Prefer | SslMode::Require => { - let ssl_opts = SslOpts::default(); + let mut ssl_opts = SslOpts::default() + .with_danger_accept_invalid_certs(true); + + if let (Some(ref cert), Some(ref key)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + ssl_opts = ssl_opts.with_client_identity(Some(ClientIdentity::new( + std::path::PathBuf::from(cert).into(), + std::path::PathBuf::from(key).into(), + ))); + } + + opts = opts.ssl_opts(Some(ssl_opts)); + } + SslMode::VerifyCA => { + let mut ssl_opts = SslOpts::default() + .with_danger_skip_domain_validation(true); + + if let Some(ref ca_cert) = self.config.ssl_ca_cert { + let ca_path: std::path::PathBuf = ca_cert.into(); + ssl_opts = ssl_opts.with_root_certs(vec![ca_path.into()]); + } + + if let (Some(ref cert), Some(ref key)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + ssl_opts = ssl_opts.with_client_identity(Some(ClientIdentity::new( + std::path::PathBuf::from(cert).into(), + std::path::PathBuf::from(key).into(), + ))); + } + opts = opts.ssl_opts(Some(ssl_opts)); } - SslMode::VerifyCA | SslMode::VerifyFull => { + SslMode::VerifyFull => { let mut ssl_opts = SslOpts::default(); if let Some(ref ca_cert) = self.config.ssl_ca_cert { @@ -175,6 +208,15 @@ impl MySQLAdapter { ssl_opts = ssl_opts.with_root_certs(vec![ca_path.into()]); } + if let (Some(ref cert), Some(ref key)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + ssl_opts = ssl_opts.with_client_identity(Some(ClientIdentity::new( + std::path::PathBuf::from(cert).into(), + std::path::PathBuf::from(key).into(), + ))); + } + opts = opts.ssl_opts(Some(ssl_opts)); } } @@ -278,6 +320,12 @@ impl MySQLAdapter { } } +/// Check if a mysql_async error is SSL/TLS related. +fn is_ssl_error(e: &mysql_async::Error) -> bool { + let msg = e.to_string().to_lowercase(); + msg.contains("ssl") || msg.contains("tls") || msg.contains("certificate") || msg.contains("handshake") +} + #[async_trait] impl DatabaseAdapter for MySQLAdapter { type Pool = MySQLPool; @@ -286,10 +334,39 @@ impl DatabaseAdapter for MySQLAdapter { let opts = self.build_connection_opts()?; let pool = Pool::new(opts); - let mut conn = pool - .get_conn() - .await - .map_err(mysql_connection_error_to_db_error)?; + let mut conn = match pool.get_conn().await { + Ok(conn) => conn, + Err(e) if self.config.ssl_mode == SslMode::Prefer && is_ssl_error(&e) => { + log::warn!("SSL handshake failed with Prefer mode, retrying without SSL: {}", e); + + self.config.ssl_mode = SslMode::Disable; + let fallback_opts = self.build_connection_opts()?; + self.config.ssl_mode = SslMode::Prefer; + + let fallback_pool = Pool::new(fallback_opts); + let mut conn = fallback_pool + .get_conn() + .await + .map_err(|retry_err| { + DbError::Connection(format!( + "Connection failed even without SSL: {}", + retry_err + )) + })?; + + conn.query_drop("SELECT 1") + .await + .map_err(mysql_connection_error_to_db_error)?; + + drop(conn); + + self.raw_pool = Some(fallback_pool.clone()); + self.pool = Some(Arc::new(MySQLPool { pool: fallback_pool })); + + return Ok(()); + } + Err(e) => return Err(mysql_connection_error_to_db_error(e)), + }; conn.query_drop("SELECT 1") .await diff --git a/src-tauri/src/database/postgres.rs b/src-tauri/src/database/postgres.rs index 8cb81039..1581d161 100644 --- a/src-tauri/src/database/postgres.rs +++ b/src-tauri/src/database/postgres.rs @@ -5,7 +5,7 @@ use crate::database::{ adapter::DatabaseAdapter, - config::{ConnectionConfig, SslMode}, + config::{ConnectionConfig, DatabaseType, SslMode}, error::{DbError, DbResult}, pool::ConnectionPool, types::{ @@ -18,7 +18,7 @@ use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime}; use deadpool_postgres::{ Config as DeadpoolConfig, Pool, PoolConfig as DeadpoolPoolConfig, Runtime, }; -use rustls::pki_types::{CertificateDer, ServerName}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; use rustls::ClientConfig; use std::collections::HashMap; use std::fs; @@ -138,6 +138,36 @@ fn deadpool_pool_error_to_db_error(error: deadpool_postgres::PoolError) -> DbErr } } +/// Enrich deadpool error with connection context (host:port, database) so even +/// generic messages like "error communicating with the server" include the target. +fn enrich_pool_error(error: deadpool_postgres::PoolError, config: &ConnectionConfig) -> DbError { + let target = format!("{}:{}", config.host, config.port); + let db_hint = config + .database + .as_ref() + .map(|d| format!(" database={}", d)) + .unwrap_or_default(); + let ctx = format!(" (connecting to {}{})", target, db_hint); + let err = deadpool_pool_error_to_db_error(error); + match err { + DbError::Connection(msg) => { + if msg.contains(&target) { + DbError::Connection(msg) + } else { + DbError::Connection(format!("{}{}", msg, ctx)) + } + } + DbError::Timeout(msg) => { + if msg.contains(&target) { + DbError::Timeout(msg) + } else { + DbError::Timeout(format!("{}{}", msg, ctx)) + } + } + other => DbError::Connection(format!("{}{}", other, ctx)), + } +} + /// PostgreSQL connection pool wrapper. pub struct PostgresPool { pool: Pool, @@ -227,6 +257,17 @@ impl PostgresAdapter { if let Some(ref database) = self.config.database { parts.push(format!("dbname={}", database)); + } else if matches!(self.config.db_type, DatabaseType::PostgreSQL) { + // Real PostgreSQL servers always have a 'postgres' maintenance DB; + // use it as the default when the user leaves the field empty. + parts.push("dbname=postgres".to_string()); + } else { + // PG-wire-compat engines (KingbaseES, OpenGauss, HighGo, etc.) + // typically don't ship a 'postgres' database. The libpq convention + // is to fall back to the username-matching database, but + // deadpool-postgres does not implement this fallback — it requires + // an explicit dbname. Use the username as the fallback here. + parts.push(format!("dbname={}", self.config.username)); } // Add SSL mode @@ -239,6 +280,9 @@ impl PostgresAdapter { }; parts.push(format!("sslmode={}", ssl_mode)); + // Add connection timeout + parts.push(format!("connect_timeout={}", self.config.connect_timeout_secs)); + // Add additional options for (key, value) in &self.config.options { parts.push(format!("{}={}", key, value)); @@ -426,11 +470,53 @@ impl PostgresAdapter { } } + let client_auth: Option<(Vec>, PrivateKeyDer<'static>)> = + if let (Some(ref cert_path), Some(ref key_path)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + let cert_data = std::fs::read(cert_path).map_err(|e| { + DbError::Connection(format!("Failed to read client certificate: {}", e)) + })?; + let certs: Vec> = + rustls_pemfile::certs(&mut cert_data.as_slice()) + .collect::, _>>() + .map_err(|e| { + DbError::Connection( + format!("Failed to parse client certificate: {}", e), + ) + })?; + + let key_data = std::fs::read(key_path).map_err(|e| { + DbError::Connection(format!("Failed to read client key: {}", e)) + })?; + let key = rustls_pemfile::private_key(&mut key_data.as_slice()) + .map_err(|e| { + DbError::Connection(format!("Failed to parse client key: {}", e)) + })? + .ok_or_else(|| { + DbError::Connection( + "No private key found in client key file".to_string(), + ) + })?; + + Some((certs, key)) + } else { + None + }; + if skip_verification { - let config = ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerification)) - .with_no_client_auth(); + let config = if let Some((certs, key)) = client_auth { + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerification)) + .with_client_auth_cert(certs, key) + .map_err(|e| DbError::Connection(format!("Failed to set client auth: {}", e)))? + } else { + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerification)) + .with_no_client_auth() + }; return Ok(config); } @@ -465,18 +551,37 @@ impl PostgresAdapter { } if !verify_hostname { - let config = ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(ChainOnlyVerifier { - root_store: Arc::new(root_store), - })) - .with_no_client_auth(); + let config = if let Some((certs, key)) = client_auth { + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(ChainOnlyVerifier { + root_store: Arc::new(root_store), + })) + .with_client_auth_cert(certs, key) + .map_err(|e| { + DbError::Connection(format!("Failed to set client auth: {}", e)) + })? + } else { + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(ChainOnlyVerifier { + root_store: Arc::new(root_store), + })) + .with_no_client_auth() + }; return Ok(config); } - let config = ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); + let config = if let Some((certs, key)) = client_auth { + ClientConfig::builder() + .with_root_certificates(root_store) + .with_client_auth_cert(certs, key) + .map_err(|e| DbError::Connection(format!("Failed to set client auth: {}", e)))? + } else { + ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth() + }; Ok(config) } @@ -821,7 +926,10 @@ impl DatabaseAdapter for PostgresAdapter { } }; - let _client = pool.get().await.map_err(deadpool_pool_error_to_db_error)?; + let _client = pool + .get() + .await + .map_err(|e| enrich_pool_error(e, &self.config))?; self.pool = Some(Arc::new(PostgresPool { pool })); @@ -2172,7 +2280,29 @@ mod tests { assert!(conn_str.contains("port=5432")); assert!(conn_str.contains("user=postgres")); assert!(conn_str.contains("password=password")); - assert!(!conn_str.contains("dbname=")); + assert!(conn_str.contains("dbname=postgres")); + } + + #[test] + fn test_pg_compat_without_database_falls_back_to_username() { + let config = + ConnectionConfig::new(DatabaseType::OpenGauss, "10.84.1.213", 5432, "SYSTEM") + .with_password("kingbase@123"); + + let adapter = PostgresAdapter::new(config); + let conn_str = adapter.build_connection_string(); + + assert!(conn_str.contains("host=10.84.1.213")); + assert!(conn_str.contains("port=5432")); + assert!(conn_str.contains("user=SYSTEM")); + assert!( + conn_str.contains("dbname=SYSTEM"), + "PG-wire-compat engines must fall back to username as dbname (libpq convention)" + ); + assert!( + !conn_str.contains("dbname=postgres"), + "PG-wire-compat engines must not force dbname=postgres" + ); } #[test] diff --git a/src-tauri/src/database/rqlite.rs b/src-tauri/src/database/rqlite.rs index 52b5da8d..048c42ea 100644 --- a/src-tauri/src/database/rqlite.rs +++ b/src-tauri/src/database/rqlite.rs @@ -34,7 +34,7 @@ use crate::database::{ adapter::DatabaseAdapter, - config::ConnectionConfig, + config::{ConnectionConfig, SslMode}, error::{DbError, DbResult}, pool::ConnectionPool, types::{ @@ -176,20 +176,56 @@ impl RqliteAdapter { } } - /// Build the base URL (`http://host:port`) from the configuration. + /// Build the base URL from the configuration. fn build_base_url(&self) -> String { - format!("http://{}:{}", self.config.host, self.config.port) + let scheme = if self.config.ssl_mode == SslMode::Disable { "http" } else { "https" }; + format!("{}://{}:{}", scheme, self.config.host, self.config.port) } /// Create the `reqwest::Client` used for all HTTP calls. - fn build_client() -> DbResult { - reqwest::Client::builder() + fn build_client(&self) -> DbResult { + let mut builder = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) - .user_agent("sqlkit-rqlite-adapter/0.1") - .build() + .user_agent("sqlkit-rqlite-adapter/0.1"); + + builder = self.apply_ssl_to_builder(builder)?; + + builder.build() .map_err(|e| DbError::Connection(format!("Failed to create HTTP client: {}", e))) } + fn apply_ssl_to_builder(&self, mut builder: reqwest::ClientBuilder) -> DbResult { + match self.config.ssl_mode { + SslMode::Disable => {} + SslMode::Prefer | SslMode::Require => { + builder = builder.danger_accept_invalid_certs(true); + } + SslMode::VerifyCA | SslMode::VerifyFull => { + if let Some(ref ca_cert) = self.config.ssl_ca_cert { + let pem = std::fs::read(ca_cert) + .map_err(|e| DbError::Connection(format!("Failed to read CA certificate: {}", e)))?; + let cert = reqwest::Certificate::from_pem(&pem) + .map_err(|e| DbError::Connection(format!("Failed to parse CA certificate: {}", e)))?; + builder = builder.add_root_certificate(cert); + } + } + } + if let (Some(ref cert_path), Some(ref key_path)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + let cert_pem = std::fs::read(cert_path) + .map_err(|e| DbError::Connection(format!("Failed to read client certificate: {}", e)))?; + let key_pem = std::fs::read(key_path) + .map_err(|e| DbError::Connection(format!("Failed to read client key: {}", e)))?; + let mut combined = cert_pem; + combined.extend_from_slice(&key_pem); + let identity = reqwest::Identity::from_pem(&combined) + .map_err(|e| DbError::Connection(format!("Failed to parse client identity: {}", e)))?; + builder = builder.identity(identity); + } + Ok(builder) + } + /// Build the HTTP headers for a request. /// /// Adds `Content-Type: application/json` and a `Basic` authorization header when @@ -335,7 +371,7 @@ impl DatabaseAdapter for RqliteAdapter { type Pool = RqlitePool; async fn connect(&mut self) -> DbResult<()> { - let client = Self::build_client()?; + let client = self.build_client()?; // Verify connectivity by sending SELECT 1 let url = format!("{}/db/query", self.build_base_url()); @@ -702,18 +738,36 @@ mod tests { #[test] fn test_build_base_url() { let config = - ConnectionConfig::new(DatabaseType::RQLite, "rqlite.example.com", 4001, "default"); + ConnectionConfig::new(DatabaseType::RQLite, "rqlite.example.com", 4001, "default") + .with_ssl_mode(SslMode::Disable); let adapter = RqliteAdapter::new(config); assert_eq!(adapter.build_base_url(), "http://rqlite.example.com:4001"); } #[test] fn test_build_base_url_non_default_port() { - let config = ConnectionConfig::new(DatabaseType::RQLite, "localhost", 4001, "default"); + let config = ConnectionConfig::new(DatabaseType::RQLite, "localhost", 4001, "default") + .with_ssl_mode(SslMode::Disable); let adapter = RqliteAdapter::new(config); assert_eq!(adapter.build_base_url(), "http://localhost:4001"); } + #[test] + fn test_build_base_url_https() { + let config = ConnectionConfig::new(DatabaseType::RQLite, "rqlite.example.com", 4001, "default") + .with_ssl_mode(SslMode::Prefer); + let adapter = RqliteAdapter::new(config); + assert_eq!(adapter.build_base_url(), "https://rqlite.example.com:4001"); + } + + #[test] + fn test_build_base_url_https_require() { + let config = ConnectionConfig::new(DatabaseType::RQLite, "rqlite.example.com", 4001, "default") + .with_ssl_mode(SslMode::Require); + let adapter = RqliteAdapter::new(config); + assert_eq!(adapter.build_base_url(), "https://rqlite.example.com:4001"); + } + // ---- Headers ---- #[test] diff --git a/src-tauri/src/database/strategy.rs b/src-tauri/src/database/strategy.rs index 68631f1f..90582a96 100644 --- a/src-tauri/src/database/strategy.rs +++ b/src-tauri/src/database/strategy.rs @@ -43,8 +43,8 @@ pub fn resolve_effective_type(db: DatabaseType) -> ConnectionStrategy { // Native PG adapter PostgreSQL => ConnectionStrategy::Native(CoreDatabaseType::PostgreSQL), // PG wire protocol compat - CockroachDB | Redshift | YugabyteDB | TimescaleDB | KingbaseES | GaussDB | HighGo - | UXDB | OpenGauss | GBase8c | QuestDB | Vastbase | YashanDB + CockroachDB | Redshift | YugabyteDB | TimescaleDB | GaussDB | HighGo + | UXDB | OpenGauss | GBase8c | QuestDB | Vastbase | Greenplum | EnterpriseDB | CrateDB | Materialize | AlloyDB | CloudSQLPG | FujitsuPG => { ConnectionStrategy::Native(CoreDatabaseType::PostgreSQL) @@ -88,6 +88,9 @@ pub fn resolve_effective_type(db: DatabaseType) -> ConnectionStrategy { Cassandra => ConnectionStrategy::JdbcBridge, Iris => ConnectionStrategy::JdbcBridge, Access => ConnectionStrategy::JdbcBridge, + YashanDB => ConnectionStrategy::JdbcBridge, + KingbaseES => ConnectionStrategy::JdbcBridge, + OceanbaseOracle => ConnectionStrategy::JdbcBridge, // HTTP SQL bridge Trino | Presto => ConnectionStrategy::Http, @@ -117,12 +120,14 @@ pub fn is_pg_family(db: DatabaseType) -> bool { pub fn default_port(db: DatabaseType) -> Option { use DatabaseType::*; match db { - PostgreSQL | CockroachDB | Redshift | YugabyteDB | TimescaleDB | KingbaseES | GaussDB + PostgreSQL | CockroachDB | Redshift | YugabyteDB | TimescaleDB | GaussDB | HighGo | UXDB | OpenGauss | GBase8c | Vastbase | Greenplum | EnterpriseDB | CrateDB | Materialize | AlloyDB | CloudSQLPG | FujitsuPG => Some(5432), QuestDB => Some(8812), YashanDB => Some(1688), + KingbaseES => Some(54321), + OceanbaseOracle => Some(2881), MySQL | MariaDB | TiDB | OceanBase | TDSQL | PolarDB | GoldenDB | SingleStoreMemSQL | CloudSQLMySQL => Some(3306), Doris | SelectDB | StarRocks => Some(9030), @@ -171,12 +176,10 @@ mod tests { DatabaseType::PostgreSQL, DatabaseType::CockroachDB, DatabaseType::Redshift, - DatabaseType::KingbaseES, DatabaseType::GaussDB, DatabaseType::HighGo, DatabaseType::QuestDB, DatabaseType::Vastbase, - DatabaseType::YashanDB, DatabaseType::Greenplum, DatabaseType::EnterpriseDB, DatabaseType::CrateDB, @@ -272,6 +275,9 @@ mod tests { DatabaseType::Cassandra, DatabaseType::Iris, DatabaseType::Access, + DatabaseType::YashanDB, + DatabaseType::KingbaseES, + DatabaseType::OceanbaseOracle, ] { assert_eq!( resolve_effective_type(db), @@ -315,6 +321,8 @@ mod tests { assert_eq!(default_port(DatabaseType::QuestDB), Some(8812)); assert_eq!(default_port(DatabaseType::Vastbase), Some(5432)); assert_eq!(default_port(DatabaseType::YashanDB), Some(1688)); + assert_eq!(default_port(DatabaseType::KingbaseES), Some(54321)); + assert_eq!(default_port(DatabaseType::OceanbaseOracle), Some(2881)); assert_eq!(default_port(DatabaseType::Hive), Some(10000)); assert_eq!(default_port(DatabaseType::Databricks), Some(443)); assert_eq!(default_port(DatabaseType::Hana), Some(30015)); diff --git a/src-tauri/src/database/turso.rs b/src-tauri/src/database/turso.rs index 8f33ec24..8794d79f 100644 --- a/src-tauri/src/database/turso.rs +++ b/src-tauri/src/database/turso.rs @@ -177,11 +177,34 @@ impl TursoAdapter { } /// Create the `reqwest::Client` used for all HTTP calls. - fn build_client() -> DbResult { - reqwest::Client::builder() + fn build_client(&self) -> DbResult { + let mut builder = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) - .user_agent("sqlkit-turso-adapter/0.1") - .build() + .user_agent("sqlkit-turso-adapter/0.1"); + + if let Some(ref ca_cert) = self.config.ssl_ca_cert { + let pem = std::fs::read(ca_cert) + .map_err(|e| DbError::Connection(format!("Failed to read CA certificate: {}", e)))?; + let cert = reqwest::Certificate::from_pem(&pem) + .map_err(|e| DbError::Connection(format!("Failed to parse CA certificate: {}", e)))?; + builder = builder.add_root_certificate(cert); + } + + if let (Some(ref cert_path), Some(ref key_path)) = + (&self.config.ssl_client_cert, &self.config.ssl_client_key) + { + let cert_pem = std::fs::read(cert_path) + .map_err(|e| DbError::Connection(format!("Failed to read client certificate: {}", e)))?; + let key_pem = std::fs::read(key_path) + .map_err(|e| DbError::Connection(format!("Failed to read client key: {}", e)))?; + let mut combined = cert_pem; + combined.extend_from_slice(&key_pem); + let identity = reqwest::Identity::from_pem(&combined) + .map_err(|e| DbError::Connection(format!("Failed to parse client identity: {}", e)))?; + builder = builder.identity(identity); + } + + builder.build() .map_err(|e| DbError::Connection(format!("Failed to create HTTP client: {}", e))) } @@ -352,7 +375,7 @@ impl DatabaseAdapter for TursoAdapter { type Pool = TursoPool; async fn connect(&mut self) -> DbResult<()> { - let client = Self::build_client()?; + let client = self.build_client()?; // Verify connectivity by sending a simple "SELECT 1" pipeline let url = format!("{}/v2/pipeline", self.build_base_url()); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index c022374f..a9776270 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -60,6 +60,14 @@ pub struct ServerConfig { /// SSL mode configuration. #[serde(skip_serializing_if = "Option::is_none")] pub ssl_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_ca_cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_client_cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_client_key: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub trust_server_certificate: bool, /// Additional metadata. #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, @@ -93,6 +101,10 @@ impl ServerConfig { password: None, database: None, ssl_mode: None, + ssl_ca_cert: None, + ssl_client_cert: None, + ssl_client_key: None, + trust_server_certificate: false, metadata: None, oracle_options: None, connect_timeout_secs: default_timeout_10(), @@ -127,6 +139,7 @@ impl ServerConfig { "mariadb" => Ok(DatabaseType::MariaDB), "tidb" => Ok(DatabaseType::TiDB), "oceanbase" => Ok(DatabaseType::OceanBase), + "oceanbase-oracle" | "oceanbase_oracle" => Ok(DatabaseType::OceanbaseOracle), "tdsql" => Ok(DatabaseType::TDSQL), "polardb" => Ok(DatabaseType::PolarDB), "kingbasees" | "kingbase" => Ok(DatabaseType::KingbaseES), @@ -197,17 +210,29 @@ impl ServerConfig { "disable" => crate::database::SslMode::Disable, "prefer" => crate::database::SslMode::Prefer, "require" => crate::database::SslMode::Require, + "verify-ca" | "verify_ca" => crate::database::SslMode::VerifyCA, + "verify-full" | "verify_full" => crate::database::SslMode::VerifyFull, _ => crate::database::SslMode::Prefer, }; config = config.with_ssl_mode(ssl); + } else { + config = config.with_ssl_mode(crate::database::SslMode::Disable); } + config = config + .with_ssl_ca_cert(self.ssl_ca_cert.clone()) + .with_ssl_client_cert(self.ssl_client_cert.clone()) + .with_ssl_client_key(self.ssl_client_key.clone()) + .with_trust_server_certificate(self.trust_server_certificate); + if let Some(ref layers) = self.transport_layers { config = config.with_transport_layers(layers.clone()); } if let Some(ref oracle_opts) = self.oracle_options { - if db_type == crate::database::DatabaseType::Oracle { + if db_type == crate::database::DatabaseType::Oracle + || db_type == crate::database::DatabaseType::OceanbaseOracle + { config = config.with_oracle_options(oracle_opts.clone()); } } diff --git a/src/assets/images/database-icons/gaussdb-logo.svg b/src/assets/images/database-icons/gaussdb-logo.svg index f115a85c..d571864e 100644 --- a/src/assets/images/database-icons/gaussdb-logo.svg +++ b/src/assets/images/database-icons/gaussdb-logo.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/components/connections/ServerCard.vue b/src/components/connections/ServerCard.vue index 7be527d5..df8f887b 100644 --- a/src/components/connections/ServerCard.vue +++ b/src/components/connections/ServerCard.vue @@ -14,7 +14,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useDatabaseIcon } from '@/composables/useDatabaseIcon' -import { ConnectionStatus, DatabaseType } from '@/store' +import { ConnectionStatus, DatabaseType, formatServerVersion, getConnectionStrategy } from '@/store' const props = defineProps<{ connection: ServerConnection @@ -59,15 +59,33 @@ const statusText = computed(() => { }) const connectionUrl = computed(() => { - const { host, port, database, type } = props.connection + const { host, port, database, type, oracleOptions } = props.connection if (type === DatabaseType.SQLITE) { return host } + if (type === DatabaseType.ORACLE && oracleOptions) { + if (oracleOptions.connectionMethod === 'tns' && oracleOptions.tnsAlias) { + return oracleOptions.tnsAlias + } + if (oracleOptions.connectionMethod === 'cloud_wallet') { + return 'Cloud Wallet' + } + } const portStr = port ? `:${port}` : '' const dbStr = database ? `/${database}` : '' return `${host}${portStr}${dbStr}` }) +const strategy = computed(() => getConnectionStrategy(props.connection.type)) +const strategyTooltip = computed(() => { + switch (strategy.value) { + case 'native': return t('components.serverCard.strategy.nativeTooltip') + case 'jdbc-bridge': return t('components.serverCard.strategy.jdbcBridgeTooltip') + case 'http-bridge': return t('components.serverCard.strategy.httpBridgeTooltip') + default: return '' + } +}) + const handleConnect = () => emit('connect', props.connection) const handleDoubleClick = () => emit('dblclick', props.connection) const handleEdit = () => emit('edit', props.connection) @@ -122,6 +140,81 @@ const handleDuplicate = () => emit('duplicate', props.connection) {{ connection.type }} + + + + + + + + + + + + + + + + + + + + + + +

+ {{ strategyTooltip }} +

+
+
+
+ + + + + {{ formatServerVersion(connection.serverVersion) }} + + + +

+ {{ connection.serverVersion }} +

+
+
+
SSL diff --git a/src/components/connections/ServerFormDialog.vue b/src/components/connections/ServerFormDialog.vue index feded3fc..064d144b 100644 --- a/src/components/connections/ServerFormDialog.vue +++ b/src/components/connections/ServerFormDialog.vue @@ -6,6 +6,7 @@ import { open as openDialog } from '@tauri-apps/plugin-dialog' import { computed, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { Button } from '@/components/ui/button' +import { SearchableSelect } from '@/components/ui/combobox' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -21,7 +22,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useDatabaseIcon } from '@/composables/useDatabaseIcon' import { toast } from '@/composables/useNotifications' import { jdbcApi } from '@/datasources/jdbcApi' -import { buildOracleOptions, buildTransportLayers, DatabaseType, dbTypeToBackend, resolveDatabase } from '@/store' +import { buildOracleOptions, buildTransportLayers, databasePlaceholderFor, DatabaseType, dbTypeToBackend, isDatabaseRequired, isJdbcDatabase, resolveDatabase } from '@/store' import { DEFAULT_SSL_MODE, sslModeToBackend, validateSslConfig } from '@/types/connection' import SslConfigSection from './ssl/SslConfigSection.vue' @@ -68,6 +69,7 @@ const defaultPorts: Record = { [DatabaseType.YASHANDB]: 1688, [DatabaseType.TIDB]: 4000, [DatabaseType.OCEANBASE]: 2883, + [DatabaseType.OCEANBASE_ORACLE]: 2881, [DatabaseType.TDSQL]: 3306, [DatabaseType.POLARDB]: 3306, [DatabaseType.DAMENG]: 5236, @@ -337,6 +339,21 @@ const isFileBased = computed(() => || formData.value.type === DatabaseType.DUCKDB, ) +const isJdbcDb = computed(() => isJdbcDatabase(formData.value.type)) + +const dbTypeI18nKeyMap: Record = { + MANTICORESEARCH: 'manticore', +} + +const databaseTypeOptions = computed(() => + Object.values(DatabaseType).map((value) => { + const i18nKey = dbTypeI18nKeyMap[value] || value.toLowerCase() + const fullKey = `components.serverForm.databaseTypes.${i18nKey}` + const label = t(fullKey) + return { label: label === fullKey ? value : label, value } + }), +) + // ── Oracle-specific state ── const isOracle = computed(() => formData.value.type === DatabaseType.ORACLE) const oracleMethod = ref<'basic' | 'tns' | 'cloud_wallet'>('basic') @@ -371,6 +388,12 @@ const databaseLabel = computed(() => { return t('components.serverForm.labels.database') }) +const isDbNameRequired = computed(() => isDatabaseRequired(formData.value.type)) + +const databasePlaceholder = computed(() => + databasePlaceholderFor[formData.value.type] || t('components.serverForm.placeholders.database'), +) + // Reset Oracle options when toggling method function resetOracleOptions() { oracleMethod.value = 'basic' @@ -551,6 +574,10 @@ function validateForm(): boolean { if (isOracle.value && !formData.value.database?.trim()) { errors.database = t('components.serverForm.errors.databaseRequired') } + // For engines with mode-dependent or no default database (EnterpriseDB, TimescaleDB, Redshift) + if (isDbNameRequired.value && !formData.value.database?.trim()) { + errors.database = t('components.serverForm.errors.databaseNameRequired') + } const dbTypeBackend = mapDatabaseTypeToBackend(formData.value.type) const sslErrors = validateSslConfig(formData.value.ssl, dbTypeBackend) @@ -582,53 +609,56 @@ async function handleTestConnection() { await startProgressListener() try { - // Run all three downloads in parallel — none depend on each other - const jreStep = setupSteps.value.find(s => s.id === 'jre')! - const bridgeStep = setupSteps.value.find(s => s.id === 'bridge')! - const driverStep = setupSteps.value.find(s => s.id === 'driver')! - const dbType = mapDatabaseTypeToBackend(formData.value.type) - const results = await Promise.allSettled([ - // JRE download (skips if already installed) - invoke('check_jre_status').then(async (status: any) => { - if (!status.installed) { - const ok = await runStep(jreStep) - if (!ok) - throw new Error('JRE download failed') - } - else { jreStep.status = 'done' } - }), - // Bridge download (skips if already installed) - invoke('check_bridge_status').then(async (status: any) => { - if (!status.installed) { - const ok = await runStep(bridgeStep) - if (!ok) - throw new Error('Bridge download failed') - } - else { bridgeStep.status = 'done' } - }), - // JDBC driver download directly from Maven Central (no Java needed) - (async () => { - try { - await invoke('download_jdbc_driver_direct', { dbType }) - driverStep.status = 'done' - } - catch { - driverStep.status = 'done' // Non-fatal - } - })(), - ]) - - // If JRE or Bridge failed, stop (keep steps visible with error state) - if (results[0].status === 'rejected' || results[1].status === 'rejected') { - const firstError = setupSteps.value.find(s => s.status === 'error') - if (firstError?.error) - testError.value = firstError.error - return + // For JDBC bridge databases, ensure JRE/bridge/driver are ready first + if (isJdbcDb.value) { + const jreStep = setupSteps.value.find(s => s.id === 'jre')! + const bridgeStep = setupSteps.value.find(s => s.id === 'bridge')! + const driverStep = setupSteps.value.find(s => s.id === 'driver')! + + const results = await Promise.allSettled([ + // JRE download (skips if already installed) + invoke('check_jre_status').then(async (status: any) => { + if (!status.installed) { + const ok = await runStep(jreStep) + if (!ok) + throw new Error('JRE download failed') + } + else { jreStep.status = 'done' } + }), + // Bridge download (skips if already installed) + invoke('check_bridge_status').then(async (status: any) => { + if (!status.installed) { + const ok = await runStep(bridgeStep) + if (!ok) + throw new Error('Bridge download failed') + } + else { bridgeStep.status = 'done' } + }), + // JDBC driver download directly from Maven Central (no Java needed) + (async () => { + try { + await invoke('download_jdbc_driver_direct', { dbType }) + driverStep.status = 'done' + } + catch { + driverStep.status = 'done' // Non-fatal + } + })(), + ]) + + // If JRE or Bridge failed, stop (keep steps visible with error state) + if (results[0].status === 'rejected' || results[1].status === 'rejected') { + const firstError = setupSteps.value.find(s => s.status === 'error') + if (firstError?.error) + testError.value = firstError.error + return + } } + const driverStep = isJdbcDb.value ? setupSteps.value.find(s => s.id === 'driver') : undefined - // Step 4: Test the actual connection + // Test the actual connection const config = { id: formData.value.id || crypto.randomUUID(), name: formData.value.name, @@ -653,13 +683,19 @@ async function handleTestConnection() { const result = await invoke<{ is_connected: boolean, server_version?: string }>('test_connection', { config }) if (result.is_connected) { - driverStep.status = 'done' + if (result.server_version) { + formData.value.serverVersion = result.server_version + } + if (driverStep) + driverStep.status = 'done' testStatus.value = 'success' } else { - driverStep.status = 'error' + if (driverStep) + driverStep.status = 'error' const msg = 'Connection returned not connected' - driverStep.error = msg + if (driverStep) + driverStep.error = msg testError.value = msg testStatus.value = 'error' } @@ -670,10 +706,10 @@ async function handleTestConnection() { testError.value = msg // If the error is Java-related, also mark JRE as failed so user can retry it if (msg.includes('Unable to locate a Java Runtime') || msg.includes('Java not found')) { - const jreStep = setupSteps.value.find(s => s.id === 'jre') - if (jreStep) { - jreStep.status = 'error' - jreStep.error = 'JRE is missing or broken' + const jreStepFound = setupSteps.value.find(s => s.id === 'jre') + if (jreStepFound) { + jreStepFound.status = 'error' + jreStepFound.error = 'JRE is missing or broken' } } const running = setupSteps.value.find(s => s.status === 'running') @@ -776,354 +812,33 @@ function handleSave() {
- + + + +
@@ -1155,12 +870,19 @@ function handleSave() {
- + +

+ {{ formErrors.database }} +

@@ -1607,14 +1329,14 @@ function handleSave() {
s.status !== 'pending'))" class="p-3 rounded-md" :class="{ 'bg-blue-50 dark:bg-blue-900/10': testStatus === 'testing', 'bg-green-50 dark:bg-green-900/10': testStatus === 'success', 'bg-red-50 dark:bg-red-900/10': testStatus === 'error', }" > - -
+ +
+
+
-
+
{{ t('common.status.testing') }}
-
+
{{ t('common.status.success') }}
-
+
{{ t('common.status.failed') }} diff --git a/src/components/connections/ssl/SslConfigSection.vue b/src/components/connections/ssl/SslConfigSection.vue index ad490d6f..3c06bc60 100644 --- a/src/components/connections/ssl/SslConfigSection.vue +++ b/src/components/connections/ssl/SslConfigSection.vue @@ -1,7 +1,7 @@ + + diff --git a/src/components/ui/combobox/index.ts b/src/components/ui/combobox/index.ts new file mode 100644 index 00000000..50a9edfa --- /dev/null +++ b/src/components/ui/combobox/index.ts @@ -0,0 +1,2 @@ +export { default as SearchableSelect } from './SearchableSelect.vue' +export type { ComboboxOption } from './types' diff --git a/src/components/ui/combobox/types.ts b/src/components/ui/combobox/types.ts new file mode 100644 index 00000000..983fc113 --- /dev/null +++ b/src/components/ui/combobox/types.ts @@ -0,0 +1,5 @@ +export type ComboboxOption = { + label: string + value: string + disabled?: boolean +} diff --git a/src/components/ui/input/Input.vue b/src/components/ui/input/Input.vue index 7ceaede9..51193021 100644 --- a/src/components/ui/input/Input.vue +++ b/src/components/ui/input/Input.vue @@ -26,6 +26,6 @@ const modelValue = useVModel(props, 'modelValue', emits, { autocomplete="off" spellcheck="false" autocorrect="off" - :class="cn('flex h-10 w-full rounded-md border border-input bg-secondary px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', props.class)" + :class="cn('flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', props.class)" > diff --git a/src/components/ui/select/SelectTrigger.vue b/src/components/ui/select/SelectTrigger.vue index 42c29131..b3489340 100644 --- a/src/components/ui/select/SelectTrigger.vue +++ b/src/components/ui/select/SelectTrigger.vue @@ -17,7 +17,7 @@ const delegatedProps = computed(() => { diff --git a/src/composables/useDatabaseIcon.ts b/src/composables/useDatabaseIcon.ts index b4e024b1..20ab1393 100644 --- a/src/composables/useDatabaseIcon.ts +++ b/src/composables/useDatabaseIcon.ts @@ -91,6 +91,7 @@ const databaseIcons: Record = { YASHANDB: { icon: yashandbLogo, color: 'bg-blue-100 dark:bg-blue-900/30' }, TIDB: { icon: tidbLogo, color: 'bg-red-100 dark:bg-red-900/30' }, OCEANBASE: { icon: oceanbaseLogo, color: 'bg-blue-100 dark:bg-blue-900/30' }, + OCEANBASE_ORACLE: { icon: oceanbaseLogo, color: 'bg-blue-100 dark:bg-blue-900/30' }, TDSQL: { icon: tdsqlLogo, color: 'bg-blue-100 dark:bg-blue-900/30' }, POLARDB: { icon: polardbLogo, color: 'bg-blue-100 dark:bg-blue-900/30' }, DAMENG: { icon: damengLogo, color: 'bg-blue-100 dark:bg-blue-900/30' }, diff --git a/src/composables/useSqlFormatter.ts b/src/composables/useSqlFormatter.ts index c3ad4c3e..9dab1b0e 100644 --- a/src/composables/useSqlFormatter.ts +++ b/src/composables/useSqlFormatter.ts @@ -31,7 +31,7 @@ const dialectMap: Record = { [DatabaseType.GBASE8C]: 'postgresql', [DatabaseType.QUESTDB]: 'postgresql', [DatabaseType.VASTBASE]: 'postgresql', - [DatabaseType.YASHANDB]: 'postgresql', + [DatabaseType.YASHANDB]: 'oracle', [DatabaseType.MYSQL]: 'mysql', [DatabaseType.MARIADB]: 'mariadb', diff --git a/src/lang/enUS.ts b/src/lang/enUS.ts index ccf3fd06..d26aca60 100644 --- a/src/lang/enUS.ts +++ b/src/lang/enUS.ts @@ -892,62 +892,63 @@ export const enUS = { password: '••••••••', }, databaseTypes: { - postgresql: 'PostgreSQL', - sqlserver: 'SQL Server', - mysql: 'MySQL', - mariadb: 'MariaDB', - sqlite: 'SQLite', - duckdb: 'DuckDB', - clickhouse: 'ClickHouse', - cockroachdb: 'CockroachDB', - redshift: 'Amazon Redshift', - yugabytedb: 'YugabyteDB', - timescaledb: 'TimescaleDB', - kingbasees: '人大金仓 (KingbaseES)', - gaussdb: '华为 GaussDB', - highgo: '瀚高数据库 (HighGo DB)', - uxdb: '优炫数据库 (UXDB)', - opengauss: 'openGauss', - gbase8c: '南大通用 GBase 8c', - questdb: 'QuestDB', - vastbase: 'Vastbase (海量数据库)', - yashandb: 'YashanDB (崖山数据库)', - tidb: 'TiDB', - oceanbase: 'OceanBase', - tdsql: 'TDSQL', - polardb: 'PolarDB', - dameng: '达梦 Dameng', - doris: 'Apache Doris', - selectdb: 'SelectDB', - starrocks: 'StarRocks', - databend: 'Databend', - goldendb: 'GoldenDB', - manticore: 'Manticore Search', - oracle: 'Oracle', - db2: 'IBM Db2', - h2: 'H2', - snowflake: 'Snowflake', - xugudb: '虚谷数据库 (XuguDB)', - gbase8a: '南大通用 GBase 8a', - trino: 'Trino', - presto: 'Presto', - derby: 'Apache Derby', - hive: 'Apache Hive', - databricks: 'Databricks SQL', - hana: 'SAP HANA', - teradata: 'Teradata', - vertica: 'Vertica', - exasol: 'Exasol', - bigquery: 'Google BigQuery', - informix: 'IBM Informix', - kylin: 'Apache Kylin', - cassandra: 'Apache Cassandra', - iris: 'InterSystems IRIS', - access: 'Microsoft Access', - firebird: 'Firebird', - rqlite: 'RQLite', - turso: 'Turso (libSQL)', - tdengine: 'TDengine', + 'postgresql': 'PostgreSQL', + 'sqlserver': 'SQL Server', + 'mysql': 'MySQL', + 'mariadb': 'MariaDB', + 'sqlite': 'SQLite', + 'duckdb': 'DuckDB', + 'clickhouse': 'ClickHouse', + 'cockroachdb': 'CockroachDB', + 'redshift': 'Amazon Redshift', + 'yugabytedb': 'YugabyteDB', + 'timescaledb': 'TimescaleDB', + 'kingbasees': '人大金仓 (KingbaseES)', + 'gaussdb': '华为 GaussDB', + 'highgo': '瀚高数据库 (HighGo DB)', + 'uxdb': '优炫数据库 (UXDB)', + 'opengauss': 'openGauss', + 'gbase8c': '南大通用 GBase 8c', + 'questdb': 'QuestDB', + 'vastbase': 'Vastbase (海量数据库)', + 'yashandb': 'YashanDB (崖山数据库)', + 'tidb': 'TiDB', + 'oceanbase': 'OceanBase', + 'oceanbase-oracle': 'OceanBase Oracle', + 'tdsql': 'TDSQL', + 'polardb': 'PolarDB', + 'dameng': '达梦 Dameng', + 'doris': 'Apache Doris', + 'selectdb': 'SelectDB', + 'starrocks': 'StarRocks', + 'databend': 'Databend', + 'goldendb': 'GoldenDB', + 'manticore': 'Manticore Search', + 'oracle': 'Oracle', + 'db2': 'IBM Db2', + 'h2': 'H2', + 'snowflake': 'Snowflake', + 'xugudb': '虚谷数据库 (XuguDB)', + 'gbase8a': '南大通用 GBase 8a', + 'trino': 'Trino', + 'presto': 'Presto', + 'derby': 'Apache Derby', + 'hive': 'Apache Hive', + 'databricks': 'Databricks SQL', + 'hana': 'SAP HANA', + 'teradata': 'Teradata', + 'vertica': 'Vertica', + 'exasol': 'Exasol', + 'bigquery': 'Google BigQuery', + 'informix': 'IBM Informix', + 'kylin': 'Apache Kylin', + 'cassandra': 'Apache Cassandra', + 'iris': 'InterSystems IRIS', + 'access': 'Microsoft Access', + 'firebird': 'Firebird', + 'rqlite': 'RQLite', + 'turso': 'Turso (libSQL)', + 'tdengine': 'TDengine', }, oracle: { connectionMethod: 'Connection Method', @@ -999,6 +1000,7 @@ export const enUS = { nameRequired: 'Connection name is required', hostRequired: 'Host is required', databaseRequired: 'SID / Service Name is required', + databaseNameRequired: 'Database name is required', filePathRequired: 'Database file path is required', portInvalid: 'Port must be a positive number', filePickerFailed: 'Failed to open file picker', @@ -1029,6 +1031,11 @@ export const enUS = { duplicate: 'Duplicate', delete: 'Delete', }, + strategy: { + nativeTooltip: 'Direct connection via native Rust driver — best performance, no external dependencies', + jdbcBridgeTooltip: 'Connects through JDBC driver via embedded Java runtime — widest database compatibility', + httpBridgeTooltip: 'Connects via HTTP SQL protocol — for REST-based databases', + }, }, connectingModal: { connecting: 'Connecting to {name}...', diff --git a/src/lang/zhCN.ts b/src/lang/zhCN.ts index 42f6ffce..269e1bda 100644 --- a/src/lang/zhCN.ts +++ b/src/lang/zhCN.ts @@ -892,62 +892,63 @@ export const zhCN = { password: '••••••••', }, databaseTypes: { - postgresql: 'PostgreSQL', - sqlserver: 'SQL Server', - mysql: 'MySQL', - mariadb: 'MariaDB', - sqlite: 'SQLite', - duckdb: 'DuckDB', - clickhouse: 'ClickHouse', - cockroachdb: 'CockroachDB', - redshift: 'Amazon Redshift', - yugabytedb: 'YugabyteDB', - timescaledb: 'TimescaleDB', - kingbasees: 'KingbaseES (人大金仓)', - gaussdb: 'GaussDB (华为)', - highgo: 'HighGo DB (瀚高数据库)', - uxdb: 'UXDB (优炫数据库)', - opengauss: 'openGauss', - gbase8c: 'GBase 8c (南大通用)', - questdb: 'QuestDB', - vastbase: '海量数据库 (Vastbase)', - yashandb: '崖山数据库 (YashanDB)', - tidb: 'TiDB', - oceanbase: 'OceanBase', - tdsql: 'TDSQL', - polardb: 'PolarDB', - dameng: 'Dameng (达梦)', - doris: 'Apache Doris', - selectdb: 'SelectDB', - starrocks: 'StarRocks', - databend: 'Databend', - goldendb: 'GoldenDB', - manticore: 'Manticore Search', - oracle: 'Oracle', - db2: 'IBM Db2', - h2: 'H2', - snowflake: 'Snowflake', - xugudb: 'XuguDB (虚谷数据库)', - gbase8a: 'GBase 8a (南大通用)', - trino: 'Trino', - presto: 'Presto', - derby: 'Apache Derby', - hive: 'Apache Hive', - databricks: 'Databricks SQL', - hana: 'SAP HANA (思爱普)', - teradata: 'Teradata (天睿)', - vertica: 'Vertica (微策略)', - exasol: 'Exasol', - bigquery: 'Google BigQuery', - informix: 'IBM Informix', - kylin: 'Apache Kylin', - cassandra: 'Apache Cassandra', - iris: 'InterSystems IRIS', - access: 'Microsoft Access', - firebird: 'Firebird', - rqlite: 'RQLite', - turso: 'Turso (libSQL)', - tdengine: 'TDengine', + 'postgresql': 'PostgreSQL', + 'sqlserver': 'SQL Server', + 'mysql': 'MySQL', + 'mariadb': 'MariaDB', + 'sqlite': 'SQLite', + 'duckdb': 'DuckDB', + 'clickhouse': 'ClickHouse', + 'cockroachdb': 'CockroachDB', + 'redshift': 'Amazon Redshift', + 'yugabytedb': 'YugabyteDB', + 'timescaledb': 'TimescaleDB', + 'kingbasees': 'KingbaseES (人大金仓)', + 'gaussdb': 'GaussDB (华为)', + 'highgo': 'HighGo DB (瀚高数据库)', + 'uxdb': 'UXDB (优炫数据库)', + 'opengauss': 'openGauss', + 'gbase8c': 'GBase 8c (南大通用)', + 'questdb': 'QuestDB', + 'vastbase': '海量数据库 (Vastbase)', + 'yashandb': '崖山数据库 (YashanDB)', + 'tidb': 'TiDB', + 'oceanbase': 'OceanBase', + 'oceanbase-oracle': 'OceanBase Oracle', + 'tdsql': 'TDSQL', + 'polardb': 'PolarDB', + 'dameng': 'Dameng (达梦)', + 'doris': 'Apache Doris', + 'selectdb': 'SelectDB', + 'starrocks': 'StarRocks', + 'databend': 'Databend', + 'goldendb': 'GoldenDB', + 'manticore': 'Manticore Search', + 'oracle': 'Oracle', + 'db2': 'IBM Db2', + 'h2': 'H2', + 'snowflake': 'Snowflake', + 'xugudb': 'XuguDB (虚谷数据库)', + 'gbase8a': 'GBase 8a (南大通用)', + 'trino': 'Trino', + 'presto': 'Presto', + 'derby': 'Apache Derby', + 'hive': 'Apache Hive', + 'databricks': 'Databricks SQL', + 'hana': 'SAP HANA (思爱普)', + 'teradata': 'Teradata (天睿)', + 'vertica': 'Vertica (微策略)', + 'exasol': 'Exasol', + 'bigquery': 'Google BigQuery', + 'informix': 'IBM Informix', + 'kylin': 'Apache Kylin', + 'cassandra': 'Apache Cassandra', + 'iris': 'InterSystems IRIS', + 'access': 'Microsoft Access', + 'firebird': 'Firebird', + 'rqlite': 'RQLite', + 'turso': 'Turso (libSQL)', + 'tdengine': 'TDengine', }, oracle: { connectionMethod: '连接方式', @@ -999,6 +1000,7 @@ export const zhCN = { nameRequired: '连接名称是必填项', hostRequired: '主机地址是必填项', databaseRequired: 'SID / 服务名是必填项', + databaseNameRequired: '数据库名称是必填项', filePathRequired: '数据库文件路径是必填项', portInvalid: '端口必须是正数', filePickerFailed: '打开文件选择器失败', @@ -1029,6 +1031,11 @@ export const zhCN = { duplicate: '复制', delete: '删除', }, + strategy: { + nativeTooltip: '通过原生 Rust 驱动直接连接 — 性能最佳,无需外部依赖', + jdbcBridgeTooltip: '通过内嵌 Java 运行时的 JDBC 驱动连接 — 兼容性最广', + httpBridgeTooltip: '通过 HTTP SQL 协议连接 — 适用于 REST 接口数据库', + }, }, connectingModal: { connecting: '正在连接 {name}...', diff --git a/src/pages/ConnectionsPage.vue b/src/pages/ConnectionsPage.vue index 8a4465bd..9a018295 100644 --- a/src/pages/ConnectionsPage.vue +++ b/src/pages/ConnectionsPage.vue @@ -203,8 +203,8 @@ function getConnectionStatus(connectionId: string | undefined): ConnectionStatus