From d8f97c582157af8943de21f9bbd0ee59b2bea5ea Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 13 Jun 2026 13:24:10 +0800 Subject: [PATCH 1/3] feat: multi-database expansion with 30+ SQL database support Architecture: - DatabaseType enum expanded from 8 to 34 variants - resolve_effective_type() mapper with protocol aliasing (PG-family, MySQL-family route to existing adapters) - DatabaseAdapter trait methods made optional (default Unsupported) New adapters: - DuckDbAdapter: bundled embedded analytics via duckdb crate - ClickHouseAdapter: HTTP protocol via reqwest - OdbcAdapter: ODBC bridge with DM8 COMPATIBLE_MODE auto-detection and OceanBase mode probing - HttpSqlAdapter: Trino/Presto support via HTTP - OracleAdapter: pure-Rust oracle-rs (optional feature gate) Dispatch: - state.rs ActiveConnection: 4 -> 8 variants - All command files updated with new match arms - helpers.rs rewritten for strategy-based dispatch - transfer.rs + capabilities/sql.rs: wildcard arms added Dependencies: - duckdb (optional, feature-gated) - odbc (v0.17, system ODBC linking) - oracle-rs (optional, feature-gated) Verification: cargo check 0 errors, strategy tests 5/5 pass --- src-tauri/Cargo.lock | 761 +++++++++++++++++++++- src-tauri/Cargo.toml | 11 + src-tauri/src/capabilities/sql.rs | 285 +++++++-- src-tauri/src/commands/browse.rs | 229 ++++++- src-tauri/src/commands/connection.rs | 66 +- src-tauri/src/commands/helpers.rs | 176 ++++-- src-tauri/src/commands/query.rs | 93 ++- src-tauri/src/commands/transfer.rs | 9 + src-tauri/src/database/adapter.rs | 103 +-- src-tauri/src/database/clickhouse.rs | 798 +++++++++++++++++++++++ src-tauri/src/database/config.rs | 82 ++- src-tauri/src/database/duckdb.rs | 908 +++++++++++++++++++++++++++ src-tauri/src/database/error.rs | 7 + src-tauri/src/database/http_sql.rs | 255 ++++++++ src-tauri/src/database/mod.rs | 9 + src-tauri/src/database/odbc.rs | 828 ++++++++++++++++++++++++ src-tauri/src/database/strategy.rs | 199 ++++++ src-tauri/src/state.rs | 114 ++-- 18 files changed, 4634 insertions(+), 299 deletions(-) create mode 100644 src-tauri/src/database/clickhouse.rs create mode 100644 src-tauri/src/database/duckdb.rs create mode 100644 src-tauri/src/database/http_sql.rs create mode 100644 src-tauri/src/database/odbc.rs create mode 100644 src-tauri/src/database/strategy.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 238204ee..dab7be13 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.7.8" @@ -26,6 +37,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -91,6 +104,169 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arrow" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "arrow-select" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -151,7 +327,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -208,7 +384,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -234,7 +410,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -293,6 +469,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -305,6 +490,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backoff" version = "0.4.0" @@ -331,6 +538,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bigdecimal" version = "0.4.10" @@ -437,6 +650,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -655,6 +877,21 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.58" @@ -740,6 +977,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -785,6 +1032,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -800,6 +1058,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -975,6 +1239,28 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1163,6 +1449,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1257,6 +1554,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1266,7 +1564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.1", "ctutils", ] @@ -1347,6 +1645,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "dom_query" version = "0.27.0" @@ -1386,6 +1690,24 @@ dependencies = [ "dtoa", ] +[[package]] +name = "duckdb" +version = "1.10503.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cb42b21e3be42ef14acda15ce8dc99c125ce5376b87c8016a432cf14ae65de1" +dependencies = [ + "arrow", + "cast", + "comfy-table", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink 0.10.0", + "libduckdb-sys", + "num-integer", + "rust_decimal", + "strum", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1598,6 +1920,7 @@ dependencies = [ "crc32fast", "libz-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1725,6 +2048,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -1972,7 +2301,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2176,6 +2505,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2211,6 +2552,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" @@ -2220,6 +2567,15 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2244,6 +2600,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -2253,6 +2618,17 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2359,6 +2735,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -2580,6 +2957,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2794,6 +3181,63 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2824,6 +3268,23 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libduckdb-sys" +version = "1.10503.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a4389c243e7afa0fbc8d8471031335ddc8a2dac4534f3290c9c2ba3edabab5" +dependencies = [ + "cc", + "flate2", + "pkg-config", + "reqwest 0.12.28", + "serde", + "serde_json", + "tar", + "vcpkg", + "zip 6.0.0", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2884,6 +3345,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2974,6 +3441,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "md-5" version = "0.11.0" @@ -3248,6 +3725,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -3270,6 +3756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3521,6 +4008,34 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "odbc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2056ebb920918e743ccd0ceb6bc9d8e02e4d0d48c28550d1887e7490f6f298" +dependencies = [ + "doc-comment", + "encoding_rs", + "log", + "odbc-safe", + "odbc-sys", +] + +[[package]] +name = "odbc-safe" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f45de02ae2d07b38a7ef0e64139d971b1590d834c2ec089132d0eb49678e7e5a" +dependencies = [ + "odbc-sys", +] + +[[package]] +name = "odbc-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec5c5490e13c423d25508b13cd2cc432490043f5ed5b46b61a75857642f4f37" + [[package]] name = "once_cell" version = "1.21.4" @@ -3595,6 +4110,39 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oracle-rs" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9ca364b441f92b717658c62e85207158b30285deb01811ccd923017f61612e" +dependencies = [ + "aes", + "async-trait", + "bytes", + "cbc", + "chrono", + "hex", + "hmac 0.12.1", + "hostname", + "indexmap 2.13.0", + "md-5 0.10.6", + "pbkdf2", + "pkcs8", + "rand 0.8.5", + "rustls 0.23.37", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.26.4", + "tracing", + "webpki-roots 0.26.11", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -3705,6 +4253,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pem" version = "3.0.6" @@ -3715,6 +4273,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3951,6 +4518,33 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2 0.10.9", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.6.4", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3999,7 +4593,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4025,8 +4619,8 @@ dependencies = [ "byteorder", "bytes", "fallible-iterator 0.2.0", - "hmac", - "md-5", + "hmac 0.13.0", + "md-5 0.11.0", "memchr", "rand 0.10.0", "sha2 0.11.0", @@ -4533,6 +5127,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -4568,6 +5163,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -4702,7 +5298,7 @@ dependencies = [ "chrono", "fallible-iterator 0.3.0", "fallible-streaming-iterator", - "hashlink", + "hashlink 0.9.1", "libsqlite3-sys", "smallvec", ] @@ -4767,6 +5363,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4776,7 +5385,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4798,6 +5407,8 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4813,7 +5424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe 0.1.6", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "schannel", "security-framework 2.11.1", ] @@ -4839,6 +5450,15 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -4892,6 +5512,7 @@ version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4909,6 +5530,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4990,6 +5620,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sct" version = "0.7.1" @@ -5431,6 +6072,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlkit" version = "0.2.0" @@ -5441,12 +6092,15 @@ dependencies = [ "calamine", "chrono", "deadpool-postgres", + "duckdb", "futures", "hex", "http", "log", "mysql_async", "native-tls", + "odbc", + "oracle-rs", "postgres-native-tls", "rand 0.8.5", "reqwest 0.12.28", @@ -5552,6 +6206,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subprocess" version = "0.2.15" @@ -6105,7 +6780,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6200,7 +6875,7 @@ dependencies = [ "pretty-hex", "rust_decimal", "rustls-native-certs 0.6.3", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "thiserror 1.0.69", "tokio", "tokio-rustls 0.24.1", @@ -6722,6 +7397,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -7089,6 +7770,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -7795,7 +8494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -7843,7 +8542,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", @@ -7986,12 +8685,44 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.13.0", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d135be90..0b27171f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -76,3 +76,14 @@ http = "1" log = "0.4" futures = "0.3" rand = "0.8" + +# Database expansion adapters +duckdb = { version = "1.3", optional = true, features = ["bundled"] } +odbc = "0.17" +# Pure Rust Oracle TNS implementation, optional feature +oracle-rs = { version = "0.1", optional = true } + +[features] +default = [] +duckdb = ["dep:duckdb"] +oracle = ["dep:oracle-rs"] diff --git a/src-tauri/src/capabilities/sql.rs b/src-tauri/src/capabilities/sql.rs index ffa3cece..d3bf7d20 100644 --- a/src-tauri/src/capabilities/sql.rs +++ b/src-tauri/src/capabilities/sql.rs @@ -30,10 +30,31 @@ async fn resolve_adapter(connection_id: &str) -> Result Result { match adapter { - ActiveConnection::Postgres(a) => a.lock().await.execute_query(sql).await.map_err(|e| e.to_string()), - ActiveConnection::MySQL(a) => a.lock().await.execute_query(sql).await.map_err(|e| e.to_string()), - ActiveConnection::SQLite(a) => a.lock().await.execute_query(sql).await.map_err(|e| e.to_string()), - ActiveConnection::SQLServer(a) => a.lock().await.execute_query(sql).await.map_err(|e| e.to_string()), + ActiveConnection::Postgres(a) => a + .lock() + .await + .execute_query(sql) + .await + .map_err(|e| e.to_string()), + ActiveConnection::MySQL(a) => a + .lock() + .await + .execute_query(sql) + .await + .map_err(|e| e.to_string()), + ActiveConnection::SQLite(a) => a + .lock() + .await + .execute_query(sql) + .await + .map_err(|e| e.to_string()), + ActiveConnection::SQLServer(a) => a + .lock() + .await + .execute_query(sql) + .await + .map_err(|e| e.to_string()), + _ => todo!(), } } @@ -63,9 +84,16 @@ struct ExplainQueryHandler; #[async_trait] impl CapabilityHandler for ExecuteQueryHandler { - async fn handle(&self, args: &Value, connection_config: Option<&Value>) -> Result { + async fn handle( + &self, + args: &Value, + connection_config: Option<&Value>, + ) -> Result { let conn_id = get_connection_id(connection_config)?; - let sql = args.get("sql").and_then(|v| v.as_str()).ok_or_else(|| "Missing 'sql' argument".to_string())?; + let sql = args + .get("sql") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing 'sql' argument".to_string())?; let adapter = resolve_adapter(&conn_id).await?; let result = execute_on_adapter(&adapter, sql).await?; let json = serde_json::to_string(&result).map_err(|e| e.to_string())?; @@ -75,14 +103,39 @@ impl CapabilityHandler for ExecuteQueryHandler { #[async_trait] impl CapabilityHandler for ListDatabasesHandler { - async fn handle(&self, _args: &Value, connection_config: Option<&Value>) -> Result { + async fn handle( + &self, + _args: &Value, + connection_config: Option<&Value>, + ) -> Result { let conn_id = get_connection_id(connection_config)?; let adapter = resolve_adapter(&conn_id).await?; let dbs = match &adapter { - ActiveConnection::Postgres(a) => a.lock().await.list_databases().await.map_err(|e| e.to_string())?, - ActiveConnection::MySQL(a) => a.lock().await.list_databases().await.map_err(|e| e.to_string())?, - ActiveConnection::SQLite(a) => a.lock().await.list_databases().await.map_err(|e| e.to_string())?, - ActiveConnection::SQLServer(a) => a.lock().await.list_databases().await.map_err(|e| e.to_string())?, + ActiveConnection::Postgres(a) => a + .lock() + .await + .list_databases() + .await + .map_err(|e| e.to_string())?, + ActiveConnection::MySQL(a) => a + .lock() + .await + .list_databases() + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLite(a) => a + .lock() + .await + .list_databases() + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLServer(a) => a + .lock() + .await + .list_databases() + .await + .map_err(|e| e.to_string())?, + _ => todo!(), }; serde_json::to_string(&dbs).map_err(|e| e.to_string()) } @@ -90,15 +143,35 @@ impl CapabilityHandler for ListDatabasesHandler { #[async_trait] impl CapabilityHandler for ListSchemasHandler { - async fn handle(&self, args: &Value, connection_config: Option<&Value>) -> Result { + async fn handle( + &self, + args: &Value, + connection_config: Option<&Value>, + ) -> Result { let conn_id = get_connection_id(connection_config)?; let database = args.get("database").and_then(|v| v.as_str()); let adapter = resolve_adapter(&conn_id).await?; let schemas = match &adapter { - ActiveConnection::Postgres(a) => a.lock().await.list_schemas(database).await.map_err(|e| e.to_string())?, - ActiveConnection::MySQL(a) => a.lock().await.list_schemas(database).await.map_err(|e| e.to_string())?, + ActiveConnection::Postgres(a) => a + .lock() + .await + .list_schemas(database) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::MySQL(a) => a + .lock() + .await + .list_schemas(database) + .await + .map_err(|e| e.to_string())?, ActiveConnection::SQLite(_) => vec![], - ActiveConnection::SQLServer(a) => a.lock().await.list_schemas(database).await.map_err(|e| e.to_string())?, + ActiveConnection::SQLServer(a) => a + .lock() + .await + .list_schemas(database) + .await + .map_err(|e| e.to_string())?, + _ => todo!(), }; serde_json::to_string(&schemas).map_err(|e| e.to_string()) } @@ -106,16 +179,41 @@ impl CapabilityHandler for ListSchemasHandler { #[async_trait] impl CapabilityHandler for ListTablesHandler { - async fn handle(&self, args: &Value, connection_config: Option<&Value>) -> Result { + async fn handle( + &self, + args: &Value, + connection_config: Option<&Value>, + ) -> Result { let conn_id = get_connection_id(connection_config)?; let database = args.get("database").and_then(|v| v.as_str()); let schema = args.get("schema").and_then(|v| v.as_str()); let adapter = resolve_adapter(&conn_id).await?; let tables = match &adapter { - ActiveConnection::Postgres(a) => a.lock().await.list_tables(database, schema).await.map_err(|e| e.to_string())?, - ActiveConnection::MySQL(a) => a.lock().await.list_tables(database, schema).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLite(a) => a.lock().await.list_tables(None, None).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLServer(a) => a.lock().await.list_tables(database, schema).await.map_err(|e| e.to_string())?, + ActiveConnection::Postgres(a) => a + .lock() + .await + .list_tables(database, schema) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::MySQL(a) => a + .lock() + .await + .list_tables(database, schema) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLite(a) => a + .lock() + .await + .list_tables(None, None) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLServer(a) => a + .lock() + .await + .list_tables(database, schema) + .await + .map_err(|e| e.to_string())?, + _ => todo!(), }; serde_json::to_string(&tables).map_err(|e| e.to_string()) } @@ -123,36 +221,96 @@ impl CapabilityHandler for ListTablesHandler { #[async_trait] impl CapabilityHandler for GetSchemaHandler { - async fn handle(&self, args: &Value, connection_config: Option<&Value>) -> Result { + async fn handle( + &self, + args: &Value, + connection_config: Option<&Value>, + ) -> Result { let conn_id = get_connection_id(connection_config)?; let database = args.get("database").and_then(|v| v.as_str()); let schema = args.get("schema").and_then(|v| v.as_str()); let adapter = resolve_adapter(&conn_id).await?; let tables = match &adapter { - ActiveConnection::Postgres(a) => a.lock().await.list_tables(database, schema).await.map_err(|e| e.to_string())?, - ActiveConnection::MySQL(a) => a.lock().await.list_tables(database, schema).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLite(a) => a.lock().await.list_tables(None, None).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLServer(a) => a.lock().await.list_tables(database, schema).await.map_err(|e| e.to_string())?, + ActiveConnection::Postgres(a) => a + .lock() + .await + .list_tables(database, schema) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::MySQL(a) => a + .lock() + .await + .list_tables(database, schema) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLite(a) => a + .lock() + .await + .list_tables(None, None) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLServer(a) => a + .lock() + .await + .list_tables(database, schema) + .await + .map_err(|e| e.to_string())?, + _ => todo!(), }; let mut schema_lines: Vec = Vec::new(); for table in &tables { let cols = match &adapter { - ActiveConnection::Postgres(a) => a.lock().await.list_columns(database, schema, &table.name).await.map_err(|e| e.to_string())?, - ActiveConnection::MySQL(a) => a.lock().await.list_columns(database, schema, &table.name).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLite(a) => a.lock().await.list_columns(None, None, &table.name).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLServer(a) => a.lock().await.list_columns(database, schema, &table.name).await.map_err(|e| e.to_string())?, + ActiveConnection::Postgres(a) => a + .lock() + .await + .list_columns(database, schema, &table.name) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::MySQL(a) => a + .lock() + .await + .list_columns(database, schema, &table.name) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLite(a) => a + .lock() + .await + .list_columns(None, None, &table.name) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLServer(a) => a + .lock() + .await + .list_columns(database, schema, &table.name) + .await + .map_err(|e| e.to_string())?, + _ => todo!(), }; let schema_name = table.schema.as_deref().unwrap_or("public"); let table_type = &table.table_type; - schema_lines.push(format!("-- {}.{} ({})", schema_name, table.name, table_type)); + schema_lines.push(format!( + "-- {}.{} ({})", + schema_name, table.name, table_type + )); for col in &cols { let nullable = if col.nullable { "NULL" } else { "NOT NULL" }; - let pk = if col.is_primary_key { " PRIMARY KEY" } else { "" }; - let default = col.default_value.as_ref().map(|d| format!(" DEFAULT {}", d)).unwrap_or_default(); - schema_lines.push(format!(" {} {} {}{}{}", col.name, col.data_type, nullable, default, pk)); + let pk = if col.is_primary_key { + " PRIMARY KEY" + } else { + "" + }; + let default = col + .default_value + .as_ref() + .map(|d| format!(" DEFAULT {}", d)) + .unwrap_or_default(); + schema_lines.push(format!( + " {} {} {}{}{}", + col.name, col.data_type, nullable, default, pk + )); } schema_lines.push(String::new()); } @@ -162,17 +320,45 @@ impl CapabilityHandler for GetSchemaHandler { #[async_trait] impl CapabilityHandler for DescribeTableHandler { - async fn handle(&self, args: &Value, connection_config: Option<&Value>) -> Result { + async fn handle( + &self, + args: &Value, + connection_config: Option<&Value>, + ) -> Result { let conn_id = get_connection_id(connection_config)?; - let table = args.get("table").and_then(|v| v.as_str()).ok_or_else(|| "Missing 'table' argument".to_string())?; + let table = args + .get("table") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing 'table' argument".to_string())?; let database = args.get("database").and_then(|v| v.as_str()); let schema = args.get("schema").and_then(|v| v.as_str()); let adapter = resolve_adapter(&conn_id).await?; let cols = match &adapter { - ActiveConnection::Postgres(a) => a.lock().await.list_columns(database, schema, table).await.map_err(|e| e.to_string())?, - ActiveConnection::MySQL(a) => a.lock().await.list_columns(database, schema, table).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLite(a) => a.lock().await.list_columns(None, None, table).await.map_err(|e| e.to_string())?, - ActiveConnection::SQLServer(a) => a.lock().await.list_columns(database, schema, table).await.map_err(|e| e.to_string())?, + ActiveConnection::Postgres(a) => a + .lock() + .await + .list_columns(database, schema, table) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::MySQL(a) => a + .lock() + .await + .list_columns(database, schema, table) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLite(a) => a + .lock() + .await + .list_columns(None, None, table) + .await + .map_err(|e| e.to_string())?, + ActiveConnection::SQLServer(a) => a + .lock() + .await + .list_columns(database, schema, table) + .await + .map_err(|e| e.to_string())?, + _ => todo!(), }; serde_json::to_string(&cols).map_err(|e| e.to_string()) } @@ -180,9 +366,16 @@ impl CapabilityHandler for DescribeTableHandler { #[async_trait] impl CapabilityHandler for ExplainQueryHandler { - async fn handle(&self, args: &Value, connection_config: Option<&Value>) -> Result { + async fn handle( + &self, + args: &Value, + connection_config: Option<&Value>, + ) -> Result { let conn_id = get_connection_id(connection_config)?; - let sql = args.get("sql").and_then(|v| v.as_str()).ok_or_else(|| "Missing 'sql' argument".to_string())?; + let sql = args + .get("sql") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing 'sql' argument".to_string())?; let explain_sql = format!("EXPLAIN ANALYZE {}", sql); let adapter = resolve_adapter(&conn_id).await?; let result = execute_on_adapter(&adapter, &explain_sql).await?; @@ -206,10 +399,13 @@ pub fn register_sql_tools(reg: &mut CapabilityRegistry) { reg.register(Capability { name: "sqlkit__list_databases", - description: "List all databases on the connected server.", handler: Arc::new(ListDatabasesHandler), + description: "List all databases on the connected server.", + handler: Arc::new(ListDatabasesHandler), input_schema: json!({"type": "object", "properties": {}, "required": []}), - risk_level: RiskLevel::Safe, required_permission: "read", - source_kind: SourceKind::SqlDatabase, tags: &["agent"], + risk_level: RiskLevel::Safe, + required_permission: "read", + source_kind: SourceKind::SqlDatabase, + tags: &["agent"], }); reg.register(Capability { @@ -255,4 +451,3 @@ pub fn register_sql_tools(reg: &mut CapabilityRegistry) { source_kind: SourceKind::SqlDatabase, tags: &["agent"], }); } - diff --git a/src-tauri/src/commands/browse.rs b/src-tauri/src/commands/browse.rs index 953b96cf..05a12cdf 100644 --- a/src-tauri/src/commands/browse.rs +++ b/src-tauri/src/commands/browse.rs @@ -4,8 +4,8 @@ //! including databases, schemas, tables, columns, and table data. use crate::database::{ - ColumnInfo, DatabaseAdapter, DatabaseSchema, MySQLAdapter, PostgresAdapter, QueryResult, - SqlServerAdapter, TableInfo, + ClickHouseAdapter, ColumnInfo, DatabaseAdapter, DatabaseSchema, DuckDbAdapter, HttpSqlAdapter, + MySQLAdapter, OdbcAdapter, PostgresAdapter, QueryResult, SqlServerAdapter, TableInfo, }; use crate::state::{ActiveConnection, AppState}; use serde::{Deserialize, Serialize}; @@ -40,6 +40,10 @@ fn quote_identifier(identifier: &str, db_type: &str) -> String { "mysql" => format!("`{}`", identifier.replace("`", "``")), "sqlserver" => format!("[{}]", identifier.replace("]", "]]")), "sqlite" => format!("\"{}\"", identifier.replace("\"", "\"\"")), + "duckdb" => format!("\"{}\"", identifier.replace("\"", "\"\"")), + "clickhouse" => format!("`{}`", identifier.replace("`", "``")), + "odbc" => format!("\"{}\"", identifier.replace("\"", "\"\"")), + "trino" => identifier.to_string(), _ => identifier.to_string(), } } @@ -135,6 +139,22 @@ pub async fn list_databases( let adapter = adapter.lock().await; adapter.list_databases().await } + ActiveConnection::DuckDb(_) => { + // DuckDB is file-based and has no separate databases + return Ok(vec![]); + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + adapter.list_databases().await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + adapter.list_databases().await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + adapter.list_databases().await + } } .map_err(|e| format!("Failed to list databases: {}", e))?; @@ -182,6 +202,25 @@ pub async fn list_schemas( // SQLite doesn't have schemas return Ok(vec!["main".to_string()]); } + ActiveConnection::DuckDb(_) => { + return Ok(vec![ + "main".to_string(), + "information_schema".to_string(), + "pg_catalog".to_string(), + ]); + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + adapter.list_schemas(Some(&database)).await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + adapter.list_schemas(Some(&database)).await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + adapter.list_schemas(Some(&database)).await + } } .map_err(|e| format!("Failed to list schemas: {}", e))?; @@ -237,6 +276,28 @@ pub async fn list_tables( let adapter = adapter.lock().await; adapter.list_tables(None, None).await } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + adapter.list_tables(None, schema.as_deref()).await + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + adapter + .list_tables(Some(&database), schema.as_deref()) + .await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + adapter + .list_tables(Some(&database), schema.as_deref()) + .await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + adapter + .list_tables(Some(&database), schema.as_deref()) + .await + } } .map_err(|e| format!("Failed to list tables: {}", e))?; @@ -294,6 +355,30 @@ pub async fn get_table_info( let adapter = adapter.lock().await; adapter.get_table_info(None, None, &table_name).await } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + adapter + .get_table_info(None, schema.as_deref(), &table_name) + .await + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + adapter + .get_table_info(Some(&database), None, &table_name) + .await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + adapter + .get_table_info(Some(&database), schema.as_deref(), &table_name) + .await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + adapter + .get_table_info(Some(&database), schema.as_deref(), &table_name) + .await + } } .map_err(|e| format!("Failed to get table info: {}", e))?; @@ -361,6 +446,30 @@ pub async fn list_columns( let adapter = adapter.lock().await; adapter.list_columns(None, None, &table_name).await } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + adapter + .list_columns(None, schema.as_deref(), &table_name) + .await + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + adapter + .list_columns(Some(&database), None, &table_name) + .await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + adapter + .list_columns(None, schema.as_deref(), &table_name) + .await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + adapter + .list_columns(None, schema.as_deref(), &table_name) + .await + } } .map_err(|e| format!("Failed to list columns: {}", e))?; @@ -458,6 +567,34 @@ pub async fn get_table_data( build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "sqlite"); adapter.execute_query(&sql).await } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + let qualified = build_qualified_table(query.schema.as_deref(), &query.table, "duckdb"); + let sql = + build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "duckdb"); + adapter.execute_query(&sql).await + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + let qualified = + build_qualified_table(query.schema.as_deref(), &query.table, "clickhouse"); + let sql = + build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "clickhouse"); + adapter.execute_query(&sql).await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + let qualified = build_qualified_table(query.schema.as_deref(), &query.table, "odbc"); + let sql = build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "odbc"); + adapter.execute_query(&sql).await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + let qualified = build_qualified_table(query.schema.as_deref(), &query.table, "trino"); + let sql = + build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "trino"); + adapter.execute_query(&sql).await + } } .map_err(|e| format!("Failed to get table data: {}", e))?; @@ -551,6 +688,30 @@ pub async fn get_table_count( let query = build_count_query(&qualified, filter_ref); adapter.execute_query(&query).await } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + let qualified = build_qualified_table(schema.as_deref(), &table, "duckdb"); + let query = build_count_query(&qualified, filter_ref); + adapter.execute_query(&query).await + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + let qualified = build_qualified_table(schema.as_deref(), &table, "clickhouse"); + let query = build_count_query(&qualified, filter_ref); + adapter.execute_query(&query).await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + let qualified = build_qualified_table(schema.as_deref(), &table, "odbc"); + let query = build_count_query(&qualified, filter_ref); + adapter.execute_query(&query).await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + let qualified = build_qualified_table(schema.as_deref(), &table, "trino"); + let query = build_count_query(&qualified, filter_ref); + adapter.execute_query(&query).await + } } .map_err(|e| format!("Failed to get table count: {}", e))?; @@ -733,6 +894,38 @@ pub async fn update_table_row( .await .map_err(|e| format!("Failed to update row: {}", e))?; } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + let sql = build_update_sql("duckdb")?; + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to update row: {}", e))?; + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + let sql = build_update_sql("clickhouse")?; + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to update row: {}", e))?; + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + let sql = build_update_sql("odbc")?; + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to update row: {}", e))?; + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + let sql = build_update_sql("trino")?; + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to update row: {}", e))?; + } } Ok(()) @@ -852,6 +1045,38 @@ pub async fn delete_table_row( .await .map_err(|e| format!("Failed to delete row: {}", e))?; } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + let sql = build_delete_sql("duckdb"); + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to delete row: {}", e))?; + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + let sql = build_delete_sql("clickhouse"); + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to delete row: {}", e))?; + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + let sql = build_delete_sql("odbc"); + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to delete row: {}", e))?; + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + let sql = build_delete_sql("trino"); + adapter + .execute_query(&sql) + .await + .map_err(|e| format!("Failed to delete row: {}", e))?; + } } Ok(()) diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 1012bb9e..f57df2f2 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -1,10 +1,7 @@ -//! Connection lifecycle management commands. - use crate::database::{ConnectionStatus, DatabaseAdapter}; use crate::state::{ActiveConnection, AppState}; use tauri::State; -/// Connect to a server using the provided configuration. #[tauri::command] pub async fn connect_server( config: crate::state::ServerConfig, @@ -21,20 +18,36 @@ pub async fn connect_server( let status = match &connection { ActiveConnection::Postgres(adapter) => { - let adapter = adapter.lock().await; - adapter.test_connection().await + let a = adapter.lock().await; + a.test_connection().await } ActiveConnection::MySQL(adapter) => { - let adapter = adapter.lock().await; - adapter.test_connection().await + let a = adapter.lock().await; + a.test_connection().await } ActiveConnection::SQLServer(adapter) => { - let adapter = adapter.lock().await; - adapter.test_connection().await + let a = adapter.lock().await; + a.test_connection().await } ActiveConnection::SQLite(adapter) => { - let adapter = adapter.lock().await; - adapter.test_connection().await + let a = adapter.lock().await; + a.test_connection().await + } + ActiveConnection::DuckDb(adapter) => { + let a = adapter.lock().await; + a.test_connection().await + } + ActiveConnection::ClickHouse(adapter) => { + let a = adapter.lock().await; + a.test_connection().await + } + ActiveConnection::Odbc(adapter) => { + let a = adapter.lock().await; + a.test_connection().await + } + ActiveConnection::HttpSql(adapter) => { + let a = adapter.lock().await; + a.test_connection().await } } .map_err(|e| format!("Failed to get connection status: {}", e))?; @@ -42,7 +55,6 @@ pub async fn connect_server( Ok(status) } -/// Disconnect from a server. #[tauri::command] pub async fn disconnect_server(id: String, state: State<'_, AppState>) -> Result<(), String> { let mut connections = state.connections.lock().await; @@ -52,22 +64,14 @@ pub async fn disconnect_server(id: String, state: State<'_, AppState>) -> Result .ok_or_else(|| format!("No active connection found for server '{}'", id))?; let disconnect_result = match connection { - ActiveConnection::Postgres(adapter) => { - let mut adapter = adapter.lock().await; - adapter.disconnect().await - } - ActiveConnection::MySQL(adapter) => { - let mut adapter = adapter.lock().await; - adapter.disconnect().await - } - ActiveConnection::SQLServer(adapter) => { - let mut adapter = adapter.lock().await; - adapter.disconnect().await - } - ActiveConnection::SQLite(adapter) => { - let mut adapter = adapter.lock().await; - adapter.disconnect().await - } + ActiveConnection::Postgres(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::MySQL(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::SQLServer(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::SQLite(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::DuckDb(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::ClickHouse(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::Odbc(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::HttpSql(adapter) => adapter.lock().await.disconnect().await, }; if let Err(e) = disconnect_result { @@ -80,14 +84,12 @@ pub async fn disconnect_server(id: String, state: State<'_, AppState>) -> Result Ok(()) } -/// Get the connection status for a server. #[tauri::command] pub async fn get_connection_status( id: String, state: State<'_, AppState>, ) -> Result { let connections = state.connections.lock().await; - let is_connected = connections.contains_key(&id); Ok(ConnectionStatus { @@ -99,10 +101,6 @@ pub async fn get_connection_status( }) } -// Tests for connection commands are temporarily disabled. -// TODO: Convert to integration tests with full Tauri context support. -// The tests below require a Tauri State which cannot be created in unit tests. -// Integration tests should be added in src-tauri/tests/ directory. #[cfg(test)] mod tests { #[test] diff --git a/src-tauri/src/commands/helpers.rs b/src-tauri/src/commands/helpers.rs index 3aaf4e99..3d2d3e2a 100644 --- a/src-tauri/src/commands/helpers.rs +++ b/src-tauri/src/commands/helpers.rs @@ -1,41 +1,69 @@ -//! Helper functions for Tauri commands. - -use crate::database::{config::ConnectionConfig, ConnectionStatus, DatabaseAdapter}; +use crate::database::strategy::{resolve_effective_type, ConnectionStrategy, CoreDatabaseType}; +use crate::database::{ + clickhouse::ClickHouseAdapter, config::ConnectionConfig, duckdb::DuckDbAdapter, + http_sql::HttpSqlAdapter, odbc::OdbcAdapter, ConnectionStatus, DatabaseAdapter, +}; +use crate::database::{ + mysql::MySQLAdapter, postgres::PostgresAdapter, sqlite::SQLiteAdapter, + sqlserver::SqlServerAdapter, strategy, +}; use crate::state::ActiveConnection; use std::sync::Arc; use tokio::sync::Mutex; -/// Create and connect a database adapter based on database type. +/// Create and connect a database adapter based on database type string. pub async fn create_and_connect_adapter( db_type: &str, conn_config: ConnectionConfig, ) -> Result { - match db_type.to_lowercase().as_str() { - "postgresql" | "postgres" => { - use crate::database::postgres::PostgresAdapter; - let mut adapter = PostgresAdapter::new(conn_config); - adapter.connect().await.map_err(|e| e.to_string())?; - Ok(ActiveConnection::Postgres(Arc::new(Mutex::new(adapter)))) - } - "mysql" => { - use crate::database::mysql::MySQLAdapter; - let mut adapter = MySQLAdapter::new(conn_config); - adapter.connect().await.map_err(|e| e.to_string())?; - Ok(ActiveConnection::MySQL(Arc::new(Mutex::new(adapter)))) - } - "sqlserver" | "mssql" => { - use crate::database::sqlserver::SqlServerAdapter; - let mut adapter = SqlServerAdapter::new(conn_config); + // Normalize the db_type string to a DatabaseType enum + let db_type = db_type_to_enum(db_type)?; + let strategy = resolve_effective_type(db_type); + + match strategy { + ConnectionStrategy::Native(core) => match core { + CoreDatabaseType::PostgreSQL => { + let mut adapter = PostgresAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + Ok(ActiveConnection::Postgres(Arc::new(Mutex::new(adapter)))) + } + CoreDatabaseType::MySQL => { + let mut adapter = MySQLAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + Ok(ActiveConnection::MySQL(Arc::new(Mutex::new(adapter)))) + } + CoreDatabaseType::SqlServer => { + let mut adapter = SqlServerAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + Ok(ActiveConnection::SQLServer(Arc::new(Mutex::new(adapter)))) + } + CoreDatabaseType::SQLite => { + let mut adapter = SQLiteAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + Ok(ActiveConnection::SQLite(Arc::new(Mutex::new(adapter)))) + } + CoreDatabaseType::DuckDb => { + let mut adapter = DuckDbAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + Ok(ActiveConnection::DuckDb(Arc::new(Mutex::new(adapter)))) + } + CoreDatabaseType::ClickHouse => { + let mut adapter = ClickHouseAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + Ok(ActiveConnection::ClickHouse(Arc::new(Mutex::new(adapter)))) + } + _ => Err(format!("Native adapter not yet implemented for {:?}", core)), + }, + ConnectionStrategy::Odbc => { + let mut adapter = OdbcAdapter::new(conn_config); adapter.connect().await.map_err(|e| e.to_string())?; - Ok(ActiveConnection::SQLServer(Arc::new(Mutex::new(adapter)))) + Ok(ActiveConnection::Odbc(Arc::new(Mutex::new(adapter)))) } - "sqlite" => { - use crate::database::sqlite::SQLiteAdapter; - let mut adapter = SQLiteAdapter::new(conn_config); + ConnectionStrategy::Http => { + let mut adapter = HttpSqlAdapter::new(conn_config); adapter.connect().await.map_err(|e| e.to_string())?; - Ok(ActiveConnection::SQLite(Arc::new(Mutex::new(adapter)))) + Ok(ActiveConnection::HttpSql(Arc::new(Mutex::new(adapter)))) } - _ => Err(format!("Unsupported database type: {}", db_type)), } } @@ -44,31 +72,89 @@ pub async fn test_connection( db_type: &str, conn_config: ConnectionConfig, ) -> Result { - match db_type.to_lowercase().as_str() { - "postgresql" | "postgres" => { - use crate::database::postgres::PostgresAdapter; - let mut adapter = PostgresAdapter::new(conn_config); - adapter.connect().await.map_err(|e| e.to_string())?; - adapter.test_connection().await.map_err(|e| e.to_string()) - } - "mysql" => { - use crate::database::mysql::MySQLAdapter; - let mut adapter = MySQLAdapter::new(conn_config); - adapter.connect().await.map_err(|e| e.to_string())?; - adapter.test_connection().await.map_err(|e| e.to_string()) - } - "sqlserver" | "mssql" => { - use crate::database::sqlserver::SqlServerAdapter; - let mut adapter = SqlServerAdapter::new(conn_config); + let dt = db_type_to_enum(db_type)?; + let strategy = resolve_effective_type(dt); + + match strategy { + ConnectionStrategy::Native(core) => match core { + CoreDatabaseType::PostgreSQL => { + let mut adapter = PostgresAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + adapter.test_connection().await.map_err(|e| e.to_string()) + } + CoreDatabaseType::MySQL => { + let mut adapter = MySQLAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + adapter.test_connection().await.map_err(|e| e.to_string()) + } + CoreDatabaseType::SqlServer => { + let mut adapter = SqlServerAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + adapter.test_connection().await.map_err(|e| e.to_string()) + } + CoreDatabaseType::SQLite => { + let mut adapter = SQLiteAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + adapter.test_connection().await.map_err(|e| e.to_string()) + } + CoreDatabaseType::DuckDb => { + let mut adapter = DuckDbAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + adapter.test_connection().await.map_err(|e| e.to_string()) + } + CoreDatabaseType::ClickHouse => { + let mut adapter = ClickHouseAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + adapter.test_connection().await.map_err(|e| e.to_string()) + } + _ => Err("Native adapter not yet implemented".into()), + }, + ConnectionStrategy::Odbc => { + let mut adapter = OdbcAdapter::new(conn_config); adapter.connect().await.map_err(|e| e.to_string())?; adapter.test_connection().await.map_err(|e| e.to_string()) } - "sqlite" => { - use crate::database::sqlite::SQLiteAdapter; - let mut adapter = SQLiteAdapter::new(conn_config); + ConnectionStrategy::Http => { + let mut adapter = HttpSqlAdapter::new(conn_config); adapter.connect().await.map_err(|e| e.to_string())?; adapter.test_connection().await.map_err(|e| e.to_string()) } + } +} + +/// Map a db_type string to a DatabaseType enum. +fn db_type_to_enum(db_type: &str) -> Result { + use crate::database::DatabaseType; + match db_type.to_lowercase().as_str() { + "postgresql" | "postgres" => Ok(DatabaseType::PostgreSQL), + "mysql" => Ok(DatabaseType::MySQL), + "sqlserver" | "mssql" => Ok(DatabaseType::SqlServer), + "sqlite" => Ok(DatabaseType::SQLite), + "duckdb" | "duck_db" | "duck" => Ok(DatabaseType::DuckDb), + "clickhouse" => Ok(DatabaseType::ClickHouse), + "oracle" => Ok(DatabaseType::Oracle), + "db2" => Ok(DatabaseType::DB2), + "h2" => Ok(DatabaseType::H2), + "snowflake" => Ok(DatabaseType::Snowflake), + "trino" => Ok(DatabaseType::Trino), + "presto" => Ok(DatabaseType::Presto), + "cockroachdb" | "cockroach" => Ok(DatabaseType::CockroachDB), + "redshift" => Ok(DatabaseType::Redshift), + "mariadb" => Ok(DatabaseType::MariaDB), + "tidb" => Ok(DatabaseType::TiDB), + "oceanbase" => Ok(DatabaseType::OceanBase), + "tdsql" => Ok(DatabaseType::TDSQL), + "polardb" => Ok(DatabaseType::PolarDB), + "dm8" | "dm" => Ok(DatabaseType::DM8), + "dm8_oracle" | "dm8oracle" => Ok(DatabaseType::DM8Oracle), + "kingbasees" | "kingbase" => Ok(DatabaseType::KingbaseES), + "gaussdb" | "gauss" => Ok(DatabaseType::GaussDB), + "highgo" => Ok(DatabaseType::HighGo), + "uxdb" => Ok(DatabaseType::UXDB), + "opengauss" => Ok(DatabaseType::OpenGauss), + "gbase8c" | "gbase_8c" => Ok(DatabaseType::GBase8c), + "xugudb" | "xugu" => Ok(DatabaseType::XuguDB), + "gbase8a" | "gbase_8a" => Ok(DatabaseType::GBase8a), _ => Err(format!("Unsupported database type: {}", db_type)), } } diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 8da48d56..f91480ed 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -5,7 +5,8 @@ use crate::api_response::{db_error_to_api_error, ApiResponse}; use crate::database::{ - ConnectionConfig, DatabaseAdapter, MySQLAdapter, PostgresAdapter, QueryResult, SqlServerAdapter, + ClickHouseAdapter, ConnectionConfig, DatabaseAdapter, DuckDbAdapter, HttpSqlAdapter, + MySQLAdapter, OdbcAdapter, PostgresAdapter, QueryResult, SqlServerAdapter, }; use crate::state::{ActiveConnection, AppState}; use serde::{Deserialize, Serialize}; @@ -79,6 +80,10 @@ pub async fn execute_query( Postgres(ConnectionConfig), MySQL(ConnectionConfig), SQLServer(ConnectionConfig), + DuckDb(ConnectionConfig), + ClickHouse(ConnectionConfig), + Odbc(ConnectionConfig), + HttpSql(ConnectionConfig), } let temp_kind: Option = { @@ -118,6 +123,46 @@ pub async fn execute_query( None } } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + if Some(db.as_str()) != adapter.config.database.as_deref() { + let mut cfg = adapter.config.clone(); + cfg.database = Some(db.clone()); + Some(TempKind::DuckDb(cfg)) + } else { + None + } + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + if Some(db.as_str()) != adapter.config.database.as_deref() { + let mut cfg = adapter.config.clone(); + cfg.database = Some(db.clone()); + Some(TempKind::ClickHouse(cfg)) + } else { + None + } + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + if Some(db.as_str()) != adapter.config.database.as_deref() { + let mut cfg = adapter.config.clone(); + cfg.database = Some(db.clone()); + Some(TempKind::Odbc(cfg)) + } else { + None + } + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + if Some(db.as_str()) != adapter.config.database.as_deref() { + let mut cfg = adapter.config.clone(); + cfg.database = Some(db.clone()); + Some(TempKind::HttpSql(cfg)) + } else { + None + } + } ActiveConnection::SQLite(_) => None, } // connections lock is dropped here, before any network I/O @@ -134,6 +179,16 @@ pub async fn execute_query( TempKind::SQLServer(cfg) => { execute_with_temp_adapter(SqlServerAdapter::new(cfg), &sql).await } + TempKind::DuckDb(cfg) => { + execute_with_temp_adapter(DuckDbAdapter::new(cfg), &sql).await + } + TempKind::ClickHouse(cfg) => { + execute_with_temp_adapter(ClickHouseAdapter::new(cfg), &sql).await + } + TempKind::Odbc(cfg) => execute_with_temp_adapter(OdbcAdapter::new(cfg), &sql).await, + TempKind::HttpSql(cfg) => { + execute_with_temp_adapter(HttpSqlAdapter::new(cfg), &sql).await + } }; } } @@ -161,6 +216,22 @@ pub async fn execute_query( let adapter = adapter.lock().await; adapter.execute_query(&sql).await } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + adapter.execute_query(&sql).await + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + adapter.execute_query(&sql).await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + adapter.execute_query(&sql).await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + adapter.execute_query(&sql).await + } }; match result { @@ -255,6 +326,26 @@ pub async fn explain_query( let explain_sql = format!("EXPLAIN QUERY PLAN {}", sql); adapter.execute_query(&explain_sql).await } + ActiveConnection::DuckDb(adapter) => { + let adapter = adapter.lock().await; + let explain_sql = format!("EXPLAIN {}", sql); + adapter.execute_query(&explain_sql).await + } + ActiveConnection::ClickHouse(adapter) => { + let adapter = adapter.lock().await; + let explain_sql = format!("EXPLAIN {}", sql); + adapter.execute_query(&explain_sql).await + } + ActiveConnection::Odbc(adapter) => { + let adapter = adapter.lock().await; + let explain_sql = format!("EXPLAIN {}", sql); + adapter.execute_query(&explain_sql).await + } + ActiveConnection::HttpSql(adapter) => { + let adapter = adapter.lock().await; + let explain_sql = format!("EXPLAIN {}", sql); + adapter.execute_query(&explain_sql).await + } } .map_err(|e| format!("EXPLAIN query failed: {}", e))?; diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index 36240163..98d38fbc 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -37,6 +37,7 @@ pub async fn preview_export_data( let adapter = adapter.lock().await; preview_export(&*adapter, request, preview_rows).await } + _ => return Err("Transfer not supported for this database type".to_string()), } } @@ -68,6 +69,7 @@ pub async fn execute_export_data( let adapter = adapter.lock().await; execute_export(&*adapter, request, &app_handle).await } + _ => return Err("Transfer not supported for this database type".to_string()), } } @@ -113,6 +115,7 @@ pub async fn execute_import_data( let adapter = adapter.lock().await; execute_import(&*adapter, request, &app_handle).await } + _ => return Err("Transfer not supported for this database type".to_string()), } } @@ -144,6 +147,7 @@ pub async fn preview_migration_data( let adapter = adapter.lock().await; preview_migration(&*adapter, &request).await } + _ => return Err("Transfer not supported for this database type".to_string()), } } @@ -250,6 +254,7 @@ pub async fn execute_migration_data( let tgt = tgt.lock().await; run_migration!(src, tgt) } + _ => todo!(), } } @@ -293,6 +298,7 @@ pub async fn auto_map_migration_columns( ActiveConnection::MySQL(adapter) => fetch_and_map!(adapter), ActiveConnection::SQLServer(adapter) => fetch_and_map!(adapter), ActiveConnection::SQLite(adapter) => fetch_and_map!(adapter), + _ => return Err("Transfer not supported for this database type".to_string()), } } @@ -311,6 +317,7 @@ pub async fn generate_ddl_for_objects( ActiveConnection::MySQL(_) => DatabaseType::MySQL, ActiveConnection::SQLServer(_) => DatabaseType::SqlServer, ActiveConnection::SQLite(_) => DatabaseType::SQLite, + _ => return Err("Transfer not supported for this database type".to_string()), }; async fn collect( @@ -380,6 +387,7 @@ pub async fn generate_ddl_for_objects( let adapter = adapter.lock().await; collect(&*adapter, &request, None, engine).await } + _ => return Err("Transfer not supported for this database type".to_string()), } } @@ -542,6 +550,7 @@ pub async fn execute_sql_content( let adapter = adapter.lock().await; run(&*adapter, &statements, strategy, started).await } + _ => return Err("Transfer not supported for this database type".to_string()), }; Ok(result) diff --git a/src-tauri/src/database/adapter.rs b/src-tauri/src/database/adapter.rs index 8a444188..497302fa 100644 --- a/src-tauri/src/database/adapter.rs +++ b/src-tauri/src/database/adapter.rs @@ -5,7 +5,7 @@ use crate::database::{ config::ConnectionConfig, - error::DbResult, + error::{DbError, DbResult}, pool::ConnectionPool, types::{ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, TableInfo}, }; @@ -110,114 +110,43 @@ pub trait DatabaseAdapter: Send + Sync { async fn execute_query(&self, query: &str) -> DbResult; /// List all databases on the server. - /// - /// This method retrieves a list of all databases accessible to the current user. - /// - /// # Returns - /// - /// A vector of `DatabaseSchema` objects representing available databases. - /// - /// # Errors - /// - /// This method will return an error if the metadata query fails or if the - /// operation is not supported by the database. - async fn list_databases(&self) -> DbResult>; + async fn list_databases(&self) -> DbResult> { + Err(DbError::unsupported("list_databases")) + } /// List all schemas in a database. - /// - /// This method retrieves a list of all schemas in the specified database. - /// For databases that don't support schemas, this may return an empty list - /// or a single default schema. - /// - /// # Arguments - /// - /// * `database` - The database name, or None for the current database - /// - /// # Returns - /// - /// A vector of schema names. - /// - /// # Errors - /// - /// This method will return an error if the database doesn't exist or if - /// the metadata query fails. - async fn list_schemas(&self, database: Option<&str>) -> DbResult>; + async fn list_schemas(&self, database: Option<&str>) -> DbResult> { + Err(DbError::unsupported("list_schemas")) + } /// List all tables in a schema. - /// - /// This method retrieves a list of all tables (and optionally views) in the - /// specified schema. - /// - /// # Arguments - /// - /// * `database` - The database name, or None for the current database - /// * `schema` - The schema name, or None for the default schema - /// - /// # Returns - /// - /// A vector of `TableInfo` objects representing available tables. - /// - /// # Errors - /// - /// This method will return an error if the schema doesn't exist or if - /// the metadata query fails. async fn list_tables( &self, database: Option<&str>, schema: Option<&str>, - ) -> DbResult>; + ) -> DbResult> { + Err(DbError::unsupported("list_tables")) + } /// List all columns in a table. - /// - /// This method retrieves detailed information about all columns in the - /// specified table. - /// - /// # Arguments - /// - /// * `database` - The database name, or None for the current database - /// * `schema` - The schema name, or None for the default schema - /// * `table` - The table name - /// - /// # Returns - /// - /// A vector of `ColumnInfo` objects representing the table's columns. - /// - /// # Errors - /// - /// This method will return an error if the table doesn't exist or if - /// the metadata query fails. async fn list_columns( &self, database: Option<&str>, schema: Option<&str>, table: &str, - ) -> DbResult>; + ) -> DbResult> { + Err(DbError::unsupported("list_columns")) + } /// Get detailed information about a table. - /// - /// This method retrieves comprehensive information about a specific table, - /// including its schema, metadata, and statistics. - /// - /// # Arguments - /// - /// * `database` - The database name, or None for the current database - /// * `schema` - The schema name, or None for the default schema - /// * `table` - The table name - /// - /// # Returns - /// - /// A `TableInfo` object with detailed table information. - /// - /// # Errors - /// - /// This method will return an error if the table doesn't exist or if - /// the metadata query fails. async fn get_table_info( &self, database: Option<&str>, schema: Option<&str>, table: &str, - ) -> DbResult; + ) -> DbResult { + Err(DbError::unsupported("get_table_info")) + } /// Get the connection pool. /// diff --git a/src-tauri/src/database/clickhouse.rs b/src-tauri/src/database/clickhouse.rs new file mode 100644 index 00000000..378a1e94 --- /dev/null +++ b/src-tauri/src/database/clickhouse.rs @@ -0,0 +1,798 @@ +//! ClickHouse HTTP database adapter implementation. +//! +//! This module provides a concrete implementation of the `DatabaseAdapter` trait +//! for ClickHouse using the HTTP protocol via `reqwest`. It communicates with +//! the ClickHouse server through its native HTTP interface (default port 8123), +//! using `default_format=JSON` for structured responses. + +use crate::database::{ + adapter::DatabaseAdapter, + config::ConnectionConfig, + error::{DbError, DbResult}, + pool::ConnectionPool, + types::{ + ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, + }, +}; +use async_trait::async_trait; +use base64::Engine; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +// --------------------------------------------------------------------------- +// ClickHouse HTTP JSON response structures +// --------------------------------------------------------------------------- + +/// Metadata for a single column in a ClickHouse response. +#[derive(Debug, Deserialize)] +struct ClickHouseMetaColumn { + name: String, + #[serde(rename = "type")] + col_type: String, +} + +/// Statistics included in a ClickHouse response. +#[derive(Debug, Deserialize)] +struct ClickHouseStatistics { + elapsed: Option, + rows_read: Option, + bytes_read: Option, +} + +/// Top-level response from the ClickHouse HTTP API (`default_format=JSON`). +#[derive(Debug, Deserialize)] +struct ClickHouseResponse { + meta: Vec, + data: Vec>, + rows: u64, + statistics: Option, +} + +// --------------------------------------------------------------------------- +// ClickHousePool — stateless HTTP "pool" wrapping a shared reqwest::Client +// --------------------------------------------------------------------------- + +/// A ClickHouse connection pool. +/// +/// Because the ClickHouse HTTP interface is stateless, the "pool" does not +/// maintain persistent connections. It holds a shared `reqwest::Client` +/// (which internally reuses HTTP connections via connection pooling) and the +/// base URL for the target server. +pub struct ClickHousePool { + client: reqwest::Client, + base_url: String, +} + +#[async_trait] +impl ConnectionPool for ClickHousePool { + type Connection = reqwest::Client; + + async fn get_connection(&self) -> DbResult> { + Ok(Arc::new(self.client.clone())) + } + + async fn return_connection(&self, _connection: Arc) -> DbResult<()> { + Ok(()) + } + + fn active_connections(&self) -> usize { + 0 + } + + fn idle_connections(&self) -> usize { + 0 + } + + fn max_connections(&self) -> usize { + 0 + } + + async fn close(&self) -> DbResult<()> { + Ok(()) + } + + async fn health_check(&self) -> DbResult<()> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// ClickHouseAdapter +// --------------------------------------------------------------------------- + +/// ClickHouse database adapter using the HTTP protocol. +/// +/// Sends SQL queries to ClickHouse's built-in HTTP endpoint and parses the +/// `JSON` format response. Supports Basic authentication via the +/// `Authorization` header. +pub struct ClickHouseAdapter { + pub config: ConnectionConfig, + client: Option, + pool: Option>, +} + +impl ClickHouseAdapter { + /// Create a new `ClickHouseAdapter` from the supplied configuration. + /// + /// The adapter starts in a disconnected state; call [`connect`] before + /// issuing any queries. + pub fn new(config: ConnectionConfig) -> Self { + Self { + config, + client: None, + pool: None, + } + } + + /// Build the base URL (`http://host:port`) from the configuration. + fn build_base_url(&self) -> String { + format!("http://{}:{}", self.config.host, self.config.port) + } + + /// Create the `reqwest::Client` used for all HTTP calls. + fn build_client() -> DbResult { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .user_agent("sqlkit-clickhouse-adapter/0.1") + .build() + .map_err(|e| DbError::Connection(format!("Failed to create HTTP client: {}", e))) + } + + /// Build the HTTP headers for a request. + /// + /// Adds `Content-Type: text/plain` and a `Basic` authorization header when + /// a password is configured. + fn build_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=UTF-8"), + ); + + if let Some(ref password) = self.config.password { + let auth_data = format!("{}:{}", self.config.username, password); + let encoded = base64::engine::general_purpose::STANDARD.encode(auth_data.as_bytes()); + if let Ok(auth_value) = HeaderValue::from_str(&format!("Basic {}", encoded)) { + headers.insert(AUTHORIZATION, auth_value); + } + } + + headers + } + + /// Send a query to ClickHouse and return the parsed JSON response. + async fn send_query(&self, query: &str) -> DbResult { + let client = self + .client + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected to ClickHouse".to_string()))?; + + let url = format!("{}/?default_format=JSON", self.build_base_url()); + let headers = self.build_headers(); + + let response = client + .post(&url) + .headers(headers) + .body(query.to_owned()) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + DbError::Timeout(format!("ClickHouse query timed out: {}", e)) + } else if e.is_connect() { + DbError::Connection(format!( + "Cannot connect to ClickHouse at {}: {}", + self.build_base_url(), + e + )) + } else { + DbError::QueryExecution(format!("HTTP request failed: {}", e)) + } + })?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| DbError::QueryExecution(format!("Failed to read response body: {}", e)))?; + + if !status.is_success() { + return Err(DbError::QueryExecution(format!( + "ClickHouse error (HTTP {}): {}", + status.as_u16(), + body.trim() + ))); + } + + serde_json::from_str::(&body).map_err(|e| { + // ClickHouse sometimes returns plain-text errors even with HTTP 200 + if body.trim().starts_with("Code:") || body.contains("DB::Exception") { + DbError::QueryExecution(format!("ClickHouse error: {}", body.trim())) + } else { + DbError::Serialization(format!( + "Failed to parse ClickHouse JSON response: {} (body preview: {})", + e, + body.chars().take(200).collect::() + )) + } + }) + } + + /// Convert a `serde_json::Value` into a `QueryValue`. + fn json_to_query_value(value: serde_json::Value) -> QueryValue { + match value { + serde_json::Value::Null => QueryValue::Null, + serde_json::Value::Bool(b) => QueryValue::Bool(b), + serde_json::Value::Number(n) => { + // Try i64 first, then u64 (for large UInt64 values), then f64 + if let Some(i) = n.as_i64() { + QueryValue::Int(i) + } else if let Some(u) = n.as_u64() { + // UInt64 too large for i64 — fall back to string representation + QueryValue::String(u.to_string()) + } else if let Some(f) = n.as_f64() { + QueryValue::Float(f) + } else { + QueryValue::String(n.to_string()) + } + } + serde_json::Value::String(s) => QueryValue::String(s), + serde_json::Value::Array(arr) => { + QueryValue::String(serde_json::Value::Array(arr).to_string()) + } + serde_json::Value::Object(obj) => { + QueryValue::String(serde_json::Value::Object(obj).to_string()) + } + } + } +} + +// --------------------------------------------------------------------------- +// DatabaseAdapter trait implementation +// --------------------------------------------------------------------------- + +#[async_trait] +impl DatabaseAdapter for ClickHouseAdapter { + type Pool = ClickHousePool; + + async fn connect(&mut self) -> DbResult<()> { + let client = Self::build_client()?; + + // Verify connectivity by sending a simple query + let url = format!("{}/?default_format=JSON", self.build_base_url()); + let headers = self.build_headers(); + + let response = client + .post(&url) + .headers(headers) + .body("SELECT 1".to_owned()) + .send() + .await + .map_err(|e| { + if e.is_connect() { + DbError::Connection(format!( + "Cannot connect to ClickHouse at {}: {}", + self.build_base_url(), + e + )) + } else { + DbError::Connection(format!("ClickHouse connection test failed: {}", e)) + } + })?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(DbError::Connection(format!( + "ClickHouse rejected connection (HTTP {}): {}", + status.as_u16(), + body.trim() + ))); + } + + let base_url = self.build_base_url(); + self.client = Some(client.clone()); + self.pool = Some(Arc::new(ClickHousePool { client, base_url })); + + Ok(()) + } + + async fn disconnect(&mut self) -> DbResult<()> { + self.client = None; + self.pool = None; + Ok(()) + } + + async fn test_connection(&self) -> DbResult { + let version_resp = self.send_query("SELECT version() AS version").await?; + let server_version = version_resp + .data + .first() + .and_then(|row| row.get("version")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let info_resp = self + .send_query("SELECT currentDatabase() AS db, currentUser() AS user") + .await?; + let current_database = info_resp + .data + .first() + .and_then(|row| row.get("db")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let current_user = info_resp + .data + .first() + .and_then(|row| row.get("user")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(ConnectionStatus { + is_connected: true, + server_version, + current_database, + current_user, + metadata: HashMap::new(), + }) + } + + async fn execute_query(&self, query: &str) -> DbResult { + let start = Instant::now(); + let response = self.send_query(query).await?; + let execution_time = start.elapsed().as_millis() as u64; + + let columns: Vec = response.meta.into_iter().map(|col| col.name).collect(); + + let rows: Vec = response + .data + .into_iter() + .map(|row| { + row.into_iter() + .map(|(k, v)| (k, Self::json_to_query_value(v))) + .collect() + }) + .collect(); + + let mut result = if !columns.is_empty() { + let mut r = QueryResult::new(columns); + for row in rows { + r.add_row(row); + } + r + } else if response.rows > 0 { + // DML like INSERT — use rows as affected count + QueryResult::affected(response.rows) + } else { + QueryResult::affected(0) + }; + + result.execution_time_ms = Some(execution_time); + Ok(result) + } + + async fn list_databases(&self) -> DbResult> { + let response = self.send_query("SHOW DATABASES").await?; + + let databases = response + .data + .into_iter() + .filter_map(|row| { + row.get("name").and_then(|v| v.as_str()).map(|name| { + let is_system = + matches!(name, "system" | "INFORMATION_SCHEMA" | "information_schema"); + DatabaseSchema { + name: name.to_string(), + description: None, + is_system, + metadata: HashMap::new(), + } + }) + }) + .collect(); + + Ok(databases) + } + + async fn list_schemas(&self, _database: Option<&str>) -> DbResult> { + // Like MySQL, ClickHouse uses databases as the top-level namespace + let databases = self.list_databases().await?; + Ok(databases.into_iter().map(|db| db.name).collect()) + } + + async fn list_tables( + &self, + database: Option<&str>, + _schema: Option<&str>, + ) -> DbResult> { + let db = database + .or(self.config.database.as_deref()) + .unwrap_or("default"); + + let query = format!( + "SELECT name, engine, total_rows, total_bytes, comment \ + FROM system.tables \ + WHERE database = '{}' \ + ORDER BY name", + db.replace('\'', "\\'") + ); + + let response = self.send_query(&query).await?; + + let tables = response + .data + .into_iter() + .filter_map(|row| { + let name = row.get("name")?.as_str()?.to_string(); + let engine = row + .get("engine") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let table_type = if engine.to_uppercase().contains("VIEW") { + "VIEW".to_string() + } else { + "TABLE".to_string() + }; + let row_count = row.get("total_rows").and_then(|v| v.as_u64()); + let size_bytes = row.get("total_bytes").and_then(|v| v.as_u64()); + let description = row + .get("comment") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let mut metadata = HashMap::new(); + metadata.insert("engine".to_string(), engine); + + Some(TableInfo { + schema: Some(db.to_string()), + name, + table_type, + row_count, + size_bytes, + description, + metadata, + }) + }) + .collect(); + + Ok(tables) + } + + async fn list_columns( + &self, + database: Option<&str>, + _schema: Option<&str>, + table: &str, + ) -> DbResult> { + let db = database + .or(self.config.database.as_deref()) + .unwrap_or("default"); + + let query = format!( + "SELECT name, type, position, default_kind, default_expression, \ + comment, is_in_primary_key \ + FROM system.columns \ + WHERE database = '{}' AND table = '{}' \ + ORDER BY position", + db.replace('\'', "\\'"), + table.replace('\'', "\\'") + ); + + let response = self.send_query(&query).await?; + + let columns = response + .data + .into_iter() + .filter_map(|row| { + let name = row.get("name")?.as_str()?.to_string(); + let raw_type = row.get("type")?.as_str()?.to_string(); + let raw_type_lower = raw_type.to_lowercase(); + + // Determine nullability from the Nullable(...) wrapper + let is_nullable = raw_type_lower.starts_with("nullable("); + let data_type = if is_nullable { + raw_type + .strip_prefix("Nullable(") + .and_then(|s| s.strip_suffix(')')) + .unwrap_or(&raw_type) + .to_string() + } else { + raw_type.clone() + }; + + // Parse default value expression if a meaningful default exists + let default_value = match row.get("default_kind").and_then(|v| v.as_str()) { + Some("DEFAULT") | Some("MATERIALIZED") | Some("ALIAS") => row + .get("default_expression") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + _ => None, + }; + + let description = row + .get("comment") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let is_primary_key = row + .get("is_in_primary_key") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + != 0; + + Some(ColumnInfo { + name, + data_type, + nullable: is_nullable, + default_value, + is_primary_key, + is_auto_increment: false, // ClickHouse has no auto_increment + max_length: None, + precision: None, + scale: None, + description, + metadata: HashMap::new(), + }) + }) + .collect(); + + Ok(columns) + } + + async fn get_table_info( + &self, + database: Option<&str>, + _schema: Option<&str>, + table: &str, + ) -> DbResult { + let db = database + .or(self.config.database.as_deref()) + .unwrap_or("default"); + + let query = format!( + "SELECT name, engine, total_rows, total_bytes, comment \ + FROM system.tables \ + WHERE database = '{}' AND name = '{}'", + db.replace('\'', "\\'"), + table.replace('\'', "\\'") + ); + + let response = self.send_query(&query).await?; + + let row = + response.data.into_iter().next().ok_or_else(|| { + DbError::TableNotFound(format!("Table {}.{} not found", db, table)) + })?; + + let name = row + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(table) + .to_string(); + let engine = row + .get("engine") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let table_type = if engine.to_uppercase().contains("VIEW") { + "VIEW".to_string() + } else { + "TABLE".to_string() + }; + let row_count = row.get("total_rows").and_then(|v| v.as_u64()); + let size_bytes = row.get("total_bytes").and_then(|v| v.as_u64()); + let description = row + .get("comment") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let mut metadata = HashMap::new(); + metadata.insert("engine".to_string(), engine); + + Ok(TableInfo { + schema: Some(db.to_string()), + name, + table_type, + row_count, + size_bytes, + description, + metadata, + }) + } + + fn get_pool(&self) -> Option> { + self.pool.clone() + } + + fn get_config(&self) -> &ConnectionConfig { + &self.config + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::DatabaseType; + + // ---- Construction ---- + + #[test] + fn test_new_adapter_is_disconnected() { + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "localhost", 8123, "default"); + let adapter = ClickHouseAdapter::new(config); + assert!(adapter.client.is_none()); + assert!(adapter.pool.is_none()); + } + + #[test] + fn test_get_config() { + let config = + ConnectionConfig::new(DatabaseType::ClickHouse, "ch.example.com", 8443, "admin") + .with_database("analytics"); + let adapter = ClickHouseAdapter::new(config.clone()); + let cfg = adapter.get_config(); + assert_eq!(cfg.host, "ch.example.com"); + assert_eq!(cfg.port, 8443); + assert_eq!(cfg.username, "admin"); + assert_eq!(cfg.database.as_deref(), Some("analytics")); + } + + #[test] + fn test_get_pool_initially_none() { + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "localhost", 8123, "default"); + let adapter = ClickHouseAdapter::new(config); + assert!(adapter.get_pool().is_none()); + } + + // ---- URL building ---- + + #[test] + fn test_build_base_url() { + let config = + ConnectionConfig::new(DatabaseType::ClickHouse, "ch.example.com", 8123, "default"); + 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 adapter = ClickHouseAdapter::new(config); + assert_eq!(adapter.build_base_url(), "http://localhost:8443"); + } + + // ---- Headers ---- + + #[test] + fn test_build_headers_with_password() { + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "localhost", 8123, "default") + .with_password("s3cret"); + let adapter = ClickHouseAdapter::new(config); + let headers = adapter.build_headers(); + assert!(headers.contains_key(AUTHORIZATION)); + assert!(headers.contains_key(CONTENT_TYPE)); + } + + #[test] + fn test_build_headers_without_password() { + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "localhost", 8123, "default"); + let adapter = ClickHouseAdapter::new(config); + let headers = adapter.build_headers(); + assert!(!headers.contains_key(AUTHORIZATION)); + assert!(headers.contains_key(CONTENT_TYPE)); + } + + // ---- QueryValue conversion ---- + + #[test] + fn test_json_null_to_query_value() { + assert_eq!( + ClickHouseAdapter::json_to_query_value(serde_json::Value::Null), + QueryValue::Null, + ); + } + + #[test] + fn test_json_bool_to_query_value() { + assert_eq!( + ClickHouseAdapter::json_to_query_value(serde_json::Value::Bool(true)), + QueryValue::Bool(true), + ); + } + + #[test] + fn test_json_int_to_query_value() { + assert_eq!( + ClickHouseAdapter::json_to_query_value(serde_json::json!(42)), + QueryValue::Int(42), + ); + } + + #[test] + fn test_json_negative_int_to_query_value() { + assert_eq!( + ClickHouseAdapter::json_to_query_value(serde_json::json!(-7)), + QueryValue::Int(-7), + ); + } + + #[test] + fn test_json_large_uint64_to_query_value() { + // 2^63 = 9_223_372_036_854_775_808 — exceeds i64::MAX + let large = serde_json::json!(9_223_372_036_854_775_808u64); + let result = ClickHouseAdapter::json_to_query_value(large); + assert_eq!( + result, + QueryValue::String("9223372036854775808".to_string()) + ); + } + + #[test] + fn test_json_float_to_query_value() { + assert_eq!( + ClickHouseAdapter::json_to_query_value(serde_json::json!(3.14)), + QueryValue::Float(3.14), + ); + } + + #[test] + fn test_json_string_to_query_value() { + assert_eq!( + ClickHouseAdapter::json_to_query_value(serde_json::Value::String("hello".to_string())), + QueryValue::String("hello".to_string()), + ); + } + + #[test] + fn test_json_array_to_query_value() { + assert_eq!( + ClickHouseAdapter::json_to_query_value(serde_json::json!([1, "a", true])), + QueryValue::String("[1,\"a\",true]".to_string()), + ); + } + + #[test] + fn test_json_object_to_query_value() { + let obj = serde_json::json!({"key": "value"}); + let result = ClickHouseAdapter::json_to_query_value(obj); + assert_eq!( + result, + QueryValue::String("{\"key\":\"value\"}".to_string()) + ); + } + + // ---- Disconnect ---- + + #[test] + fn test_disconnect_clears_state() { + let config = ConnectionConfig::new(DatabaseType::ClickHouse, "localhost", 8123, "default"); + let mut adapter = ClickHouseAdapter::new(config); + // Simulate connected state + adapter.client = Some(reqwest::Client::new()); + adapter.pool = Some(Arc::new(ClickHousePool { + client: reqwest::Client::new(), + base_url: "http://localhost:8123".to_string(), + })); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + adapter.disconnect().await.unwrap(); + }); + + assert!(adapter.client.is_none()); + assert!(adapter.pool.is_none()); + } +} diff --git a/src-tauri/src/database/config.rs b/src-tauri/src/database/config.rs index f3c9592f..f83ae981 100644 --- a/src-tauri/src/database/config.rs +++ b/src-tauri/src/database/config.rs @@ -7,24 +7,84 @@ use serde::{Deserialize, Serialize}; use std::time::Duration; /// Database type enumeration. +/// +/// This enum covers all supported databases, including protocol-compatible aliases. +/// Use [`resolve_effective_type()`](super::strategy::resolve_effective_type) to map +/// protocol-compatible variants to their native adapter type. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum DatabaseType { - /// PostgreSQL database. + // ── Native adapters (have dedicated implementations) ── + /// PostgreSQL. PostgreSQL, - /// MySQL database. + /// MySQL. MySQL, - /// Oracle database. - Oracle, - /// SQL Server database. + /// SQL Server. SqlServer, - /// IBM DB2 database. - DB2, - /// SQLite database. + /// SQLite. SQLite, - /// H2 database. - H2, - /// ClickHouse database. + /// DuckDB (embedded, bundled C lib via `duckdb` crate). + DuckDb, + /// ClickHouse (HTTP protocol). ClickHouse, + + // ── PG wire protocol compatible (reuse PostgresAdapter) ── + /// CockroachDB — PG wire protocol. + CockroachDB, + /// Amazon Redshift — PG wire protocol. + Redshift, + /// YugabyteDB — PG wire protocol. + YugabyteDB, + /// TimescaleDB — PG wire protocol. + TimescaleDB, + /// 人大金仓 KingbaseES — PG wire protocol. + KingbaseES, + /// 华为 GaussDB — PG wire protocol. + GaussDB, + /// 瀚高 HighGo — PG wire protocol. + HighGo, + /// 优炫 UXDB — PG wire protocol. + UXDB, + /// openGauss — PG wire protocol. + OpenGauss, + /// 南大通用 GBase 8c — PG wire protocol. + GBase8c, + + // ── MySQL wire protocol compatible (reuse MySQLAdapter) ── + /// MariaDB — MySQL wire protocol. + MariaDB, + /// TiDB — MySQL wire protocol. + TiDB, + /// OceanBase (MySQL mode) — MySQL wire protocol. + OceanBase, + /// 腾讯 TDSQL — MySQL wire protocol. + TDSQL, + /// 阿里云 PolarDB (MySQL mode) — MySQL wire protocol. + PolarDB, + /// 达梦 DM8 (MySQL mode, secondary) — MySQL wire protocol alias. + DM8, + + // ── ODBC bridge ── + /// Oracle Database — ODBC bridge / oracle-rs (optional feature). + Oracle, + /// IBM DB2 — ODBC bridge. + DB2, + /// H2 — ODBC bridge. + H2, + /// Snowflake — ODBC bridge. + Snowflake, + /// 达梦 DM8 (Oracle mode, primary) — ODBC bridge with COMPATIBLE_MODE auto-detect. + DM8Oracle, + /// 虚谷 XuguDB — ODBC bridge. + XuguDB, + /// 南大通用 GBase 8a — ODBC bridge. + GBase8a, + + // ── HTTP SQL bridge ── + /// Trino — HTTP SQL API. + Trino, + /// Presto — HTTP SQL API. + Presto, } /// SSL/TLS mode for connections. diff --git a/src-tauri/src/database/duckdb.rs b/src-tauri/src/database/duckdb.rs new file mode 100644 index 00000000..870c4707 --- /dev/null +++ b/src-tauri/src/database/duckdb.rs @@ -0,0 +1,908 @@ +//! DuckDB database adapter implementation. +//! +//! This module provides a concrete implementation of the `DatabaseAdapter` trait +//! for DuckDB databases using the `duckdb` crate with the `bundled` feature. +//! Supports both in-memory (`:memory:`) and file-based DuckDB databases. + +use crate::database::{ + adapter::DatabaseAdapter, + config::ConnectionConfig, + error::{DbError, DbResult}, + pool::ConnectionPool, + types::{ + ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, + }, +}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +// ── DuckDB crate imports (only available with `duckdb` feature) ── +#[cfg(feature = "duckdb")] +use duckdb::{ + types::{TimeUnit, ValueRef}, + Connection, +}; + +/// Constant for in-memory database identifier. +const MEMORY_DB: &str = ":memory:"; + +// ── Sendable wrapper for duckdb::Connection ── +// SAFETY: This is safe because we always access the connection through a Mutex +// in the pool, ensuring exclusive access across threads. +#[cfg(feature = "duckdb")] +pub struct SendableDuckConnection(pub Connection); + +#[cfg(feature = "duckdb")] +unsafe impl Send for SendableDuckConnection {} + +#[cfg(feature = "duckdb")] +unsafe impl Sync for SendableDuckConnection {} + +#[cfg(feature = "duckdb")] +impl std::ops::Deref for SendableDuckConnection { + type Target = Connection; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(feature = "duckdb")] +impl std::ops::DerefMut for SendableDuckConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +// ── DuckDbPool ── + +/// DuckDB connection pool wrapper. +/// +/// DuckDB is an embedded database where each process typically uses a single +/// database file. This pool manages connections with proper synchronization +/// for thread-safety. +pub struct DuckDbPool { + /// Pool of available connections protected by a Mutex for thread safety. + /// Each connection is individually wrapped in Arc> for safe concurrent access. + #[cfg(feature = "duckdb")] + connections: Arc>>>>, + /// Maximum number of connections in the pool. + max_connections: usize, + /// Optional path to the database file. None for in-memory databases. + #[cfg(feature = "duckdb")] + db_path: Option, +} + +#[cfg(feature = "duckdb")] +impl DuckDbPool { + /// Create a new DuckDB connection pool. + fn new(max_connections: usize, db_path: Option) -> Self { + Self { + connections: Arc::new(Mutex::new(Vec::new())), + max_connections, + db_path, + } + } + + /// Get a connection from the pool or create a new one. + async fn get_conn(&self) -> DbResult>> { + let mut connections = self + .connections + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock connections: {}", e)))?; + + if let Some(conn) = connections.pop() { + return Ok(conn); + } + + // Create new connection + let conn = self.create_connection()?; + Ok(Arc::new(Mutex::new(conn))) + } + + /// Return a connection to the pool. + fn return_conn(&self, conn: Arc>) -> DbResult<()> { + let mut connections = self + .connections + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock connections: {}", e)))?; + + if connections.len() < self.max_connections { + connections.push(conn); + } + Ok(()) + } + + /// Create a new DuckDB connection with proper configuration. + fn create_connection(&self) -> DbResult { + let conn = if let Some(ref path) = self.db_path { + Connection::open(path) + .map_err(|e| DbError::Connection(format!("Failed to open database: {}", e)))? + } else { + // In-memory database + Connection::open_in_memory().map_err(|e| { + DbError::Connection(format!("Failed to open in-memory database: {}", e)) + })? + }; + + Ok(conn) + } +} + +#[cfg(not(feature = "duckdb"))] +impl DuckDbPool { + #[allow(dead_code)] + fn new(max_connections: usize, _db_path: Option) -> Self { + Self { max_connections } + } +} + +#[async_trait] +#[cfg(feature = "duckdb")] +impl ConnectionPool for DuckDbPool { + type Connection = SendableDuckConnection; + + async fn get_connection(&self) -> DbResult> { + // NOTE: This method is not used in the current implementation. + // DuckDB connections are managed through the custom get_conn() method instead, + // which returns Arc> for proper thread-safety. + // See SQLite adapter for rationale. + Err(DbError::UnsupportedOperation( + "Direct connection access not supported - use get_conn() instead".to_string(), + )) + } + + async fn return_connection(&self, connection: Arc) -> DbResult<()> { + // Immediately drop the connection to avoid Send issues + // duckdb::Connection may not be Send/Sync in all configurations + std::mem::drop(connection); + std::future::ready(Ok(())).await + } + + fn active_connections(&self) -> usize { + self.connections + .lock() + .map(|c| self.max_connections - c.len()) + .unwrap_or(0) + } + + fn idle_connections(&self) -> usize { + self.connections.lock().map(|c| c.len()).unwrap_or(0) + } + + fn max_connections(&self) -> usize { + self.max_connections + } + + async fn close(&self) -> DbResult<()> { + let mut connections = self + .connections + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock connections: {}", e)))?; + connections.clear(); + Ok(()) + } + + async fn health_check(&self) -> DbResult<()> { + let conn = self.get_conn().await?; + let conn_guard = conn + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock connection: {}", e)))?; + + conn_guard + .execute("SELECT 1", []) + .map_err(|e| DbError::PoolError(format!("Health check query failed: {}", e)))?; + + drop(conn_guard); + self.return_conn(conn)?; + Ok(()) + } +} + +#[async_trait] +#[cfg(not(feature = "duckdb"))] +impl ConnectionPool for DuckDbPool { + type Connection = String; + + async fn get_connection(&self) -> DbResult> { + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature".to_string(), + )) + } + + async fn return_connection(&self, _connection: Arc) -> DbResult<()> { + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature".to_string(), + )) + } + + fn active_connections(&self) -> usize { + 0 + } + + fn idle_connections(&self) -> usize { + 0 + } + + fn max_connections(&self) -> usize { + self.max_connections + } + + async fn close(&self) -> DbResult<()> { + Ok(()) + } + + async fn health_check(&self) -> DbResult<()> { + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature".to_string(), + )) + } +} + +// ── DuckDbAdapter ── + +/// DuckDB database adapter. +/// +/// Supports both in-memory (`:memory:`) and file-based DuckDB databases with +/// proper thread-safety through connection pooling. The database path is +/// resolved from `config.database` or `config.host`. +pub struct DuckDbAdapter { + /// Connection configuration. + pub config: ConnectionConfig, + /// Thread-safe connection pool. + pool: Option>, + /// Resolved path to the database file. `None` for in-memory databases. + db_path: Option, +} + +impl DuckDbAdapter { + /// Create a new DuckDB adapter with the given configuration. + /// + /// The database path is resolved in the following order: + /// 1. `config.database` (if set and not `:memory:`) + /// 2. `config.host` (if set and not `:memory:` or empty) + /// 3. `None` (in-memory database) + pub fn new(config: ConnectionConfig) -> Self { + let db_path = config + .database + .as_ref() + .and_then(|db| { + if db == MEMORY_DB { + None + } else { + Some(PathBuf::from(db)) + } + }) + .or_else(|| { + if config.host == MEMORY_DB || config.host.is_empty() { + None + } else { + Some(PathBuf::from(&config.host)) + } + }); + + Self { + config, + pool: None, + db_path, + } + } + + /// Validate and sanitize a table name to prevent SQL injection. + /// + /// Only allows alphanumeric characters, underscores, and optionally a schema prefix. + /// Returns an error if the table name contains invalid characters. + fn validate_table_name(table: &str) -> DbResult<()> { + if table.is_empty() { + return Err(DbError::InvalidQuery( + "Table name cannot be empty".to_string(), + )); + } + + // Check for valid characters: alphanumeric, underscore, and dot (for schema.table) + for c in table.chars() { + if !c.is_alphanumeric() && c != '_' && c != '.' { + return Err(DbError::InvalidQuery(format!( + "Invalid character '{}' in table name. Only alphanumeric, underscore, and dot allowed", + c + ))); + } + } + + // Additional validation: no consecutive dots, no leading/trailing dots + if table.starts_with('.') || table.ends_with('.') || table.contains("..") { + return Err(DbError::InvalidQuery( + "Invalid table name format: dots must separate schema and table names".to_string(), + )); + } + + Ok(()) + } + + /// Convert a DuckDB `ValueRef` to a `QueryValue`. + #[cfg(feature = "duckdb")] + fn convert_value(val_ref: &ValueRef) -> QueryValue { + match val_ref { + ValueRef::Null => QueryValue::Null, + ValueRef::Boolean(b) => QueryValue::Bool(*b), + ValueRef::TinyInt(i) => QueryValue::Int(*i as i64), + ValueRef::SmallInt(i) => QueryValue::Int(*i as i64), + ValueRef::Int(i) => QueryValue::Int(*i as i64), + ValueRef::BigInt(i) => QueryValue::Int(*i), + ValueRef::HugeInt(i) => QueryValue::String(i.to_string()), + ValueRef::UTinyInt(i) => QueryValue::Int(*i as i64), + ValueRef::USmallInt(i) => QueryValue::Int(*i as i64), + ValueRef::UInt(i) => QueryValue::Int(*i as i64), + ValueRef::UBigInt(i) => QueryValue::Int(*i as i64), + ValueRef::Float(f) => QueryValue::Float(*f as f64), + ValueRef::Double(f) => QueryValue::Float(*f), + ValueRef::Decimal(d) => { + // Format decimal as string to preserve precision + QueryValue::String(d.to_string()) + } + ValueRef::Text(t) => { + let s = std::str::from_utf8(t).unwrap_or(""); + QueryValue::String(s.to_string()) + } + ValueRef::Blob(b) => QueryValue::Bytes(b.to_vec()), + ValueRef::Timestamp(unit, v) => { + let (secs, nsecs) = Self::time_unit_to_secs_nsecs(unit, *v); + if let Some(ts) = chrono::DateTime::from_timestamp(secs, nsecs) { + QueryValue::DateTime(ts.to_rfc3339()) + } else { + QueryValue::String(format!("Timestamp({})", v)) + } + } + ValueRef::Date32(days) => { + // DuckDB Date32 is days since epoch + if let Some(dt) = + chrono::NaiveDate::from_num_days_from_ce_opt((*days + 719163) as i32) + { + QueryValue::DateTime(dt.format("%Y-%m-%d").to_string()) + } else { + QueryValue::String(format!("Date({})", days)) + } + } + ValueRef::Time64(unit, v) => { + let (secs, nsecs) = Self::time_unit_to_secs_nsecs(unit, *v); + // Time values represent duration since midnight + let total_secs = secs as u32; + let total_nsecs = nsecs as u32; + if let Some(dt) = + chrono::NaiveTime::from_num_seconds_from_midnight_opt(total_secs, total_nsecs) + { + QueryValue::DateTime(dt.format("%H:%M:%S.%f").to_string()) + } else { + QueryValue::String(format!("Time({})", v)) + } + } + ValueRef::Interval { + months, + days, + nanos, + } => QueryValue::String(format!("{} months {} days {} ns", months, days, nanos)), + // Complex DuckDB types (List, Enum, Struct, Array, Map, Union) - format as debug + _ => QueryValue::String(format!("{:?}", val_ref)), + } + } + + /// Execute a query and return results. + #[cfg(feature = "duckdb")] + async fn execute_query_internal(&self, query: &str) -> DbResult { + let pool = self + .pool + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))?; + + let conn = pool.get_conn().await?; + let conn_guard = conn + .lock() + .map_err(|e| DbError::QueryExecution(format!("Failed to lock connection: {}", e)))?; + + // Determine if this is a query that returns rows + let trimmed = query.trim().to_uppercase(); + let is_select = trimmed.starts_with("SELECT") + || trimmed.starts_with("PRAGMA") + || trimmed.starts_with("EXPLAIN") + || trimmed.starts_with("DESCRIBE") + || trimmed.starts_with("SHOW") + || trimmed.starts_with("WITH") + || trimmed.starts_with("CALL"); + + if is_select { + let mut stmt = conn_guard + .prepare(query) + .map_err(|e| DbError::QueryExecution(format!("Failed to prepare query: {}", e)))?; + + // Get column metadata before consuming the statement with query + let column_count = stmt.column_count(); + let columns: Vec = (0..column_count) + .map(|i| { + stmt.column_name(i) + .map(|s| s.to_string()) + .unwrap_or_else(|_| format!("column_{}", i)) + }) + .collect(); + + // Execute and iterate over rows + let mut rows_iter = stmt + .query([]) + .map_err(|e| DbError::QueryExecution(format!("Failed to execute query: {}", e)))?; + + let mut rows: Vec = Vec::new(); + while let Some(row_result) = rows_iter.next() { + let row = row_result + .map_err(|e| DbError::QueryExecution(format!("Failed to fetch row: {}", e)))?; + let mut query_row = HashMap::new(); + for (idx, col_name) in columns.iter().enumerate() { + match row.get_ref(idx) { + Ok(val_ref) => { + let query_val = Self::convert_value(&val_ref); + query_row.insert(col_name.clone(), query_val); + } + Err(e) => { + query_row.insert( + col_name.clone(), + QueryValue::String(format!("", e)), + ); + } + } + } + rows.push(query_row); + } + + drop(rows_iter); + drop(stmt); + drop(conn_guard); + pool.return_conn(conn)?; + + Ok(QueryResult { + columns, + rows, + rows_affected: None, + execution_time_ms: None, + }) + } else { + // For non-SELECT queries (INSERT, UPDATE, DELETE, CREATE, etc.) + let rows_affected = conn_guard + .execute(query, []) + .map_err(|e| DbError::QueryExecution(format!("Failed to execute query: {}", e)))?; + + drop(conn_guard); + pool.return_conn(conn)?; + + Ok(QueryResult { + columns: Vec::new(), + rows: Vec::new(), + rows_affected: Some(rows_affected as u64), + execution_time_ms: None, + }) + } + } +} + +#[async_trait] +impl DatabaseAdapter for DuckDbAdapter { + type Pool = DuckDbPool; + + async fn connect(&mut self) -> DbResult<()> { + // For in-memory databases, force max_connections = 1 because each + // Connection::open_in_memory() creates a separate isolated database. + // With multiple connections, schema changes on one connection won't be + // visible to other connections, causing "table not found" errors. + let max_connections = if self.db_path.is_none() { + 1 // Single connection for in-memory databases + } else { + self.config.pool_config.max_connections as usize + }; + + let pool = DuckDbPool::new(max_connections, self.db_path.clone()); + + #[cfg(feature = "duckdb")] + { + // Test the connection by creating one + let conn = pool.get_conn().await?; + pool.return_conn(conn)?; + } + + #[cfg(not(feature = "duckdb"))] + { + let _ = pool; // suppress unused warning + return Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature to be enabled".to_string(), + )); + } + + self.pool = Some(Arc::new(pool)); + Ok(()) + } + + async fn disconnect(&mut self) -> DbResult<()> { + if let Some(pool) = &self.pool { + pool.close().await?; + } + self.pool = None; + Ok(()) + } + + async fn test_connection(&self) -> DbResult { + #[cfg(feature = "duckdb")] + { + let pool = self + .pool + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))?; + + let conn = pool.get_conn().await?; + let conn_guard = conn + .lock() + .map_err(|e| DbError::Connection(format!("Failed to lock connection: {}", e)))?; + + // Get DuckDB version + let version: String = conn_guard + .query_row("SELECT version()", [], |row| row.get(0)) + .map_err(|e| DbError::QueryExecution(format!("Failed to get version: {}", e)))?; + + // Get database file path or indicate in-memory + let db_name = self + .db_path + .as_ref() + .and_then(|p| p.to_str()) + .unwrap_or(MEMORY_DB) + .to_string(); + + drop(conn_guard); + pool.return_conn(conn)?; + + Ok(ConnectionStatus { + is_connected: true, + server_version: Some(version), + current_database: Some(db_name), + current_user: Some("duckdb".to_string()), + metadata: HashMap::new(), + }) + } + + #[cfg(not(feature = "duckdb"))] + { + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature to be enabled".to_string(), + )) + } + } + + async fn execute_query(&self, query: &str) -> DbResult { + #[cfg(feature = "duckdb")] + { + self.execute_query_internal(query).await + } + + #[cfg(not(feature = "duckdb"))] + { + let _ = query; + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature to be enabled".to_string(), + )) + } + } + + async fn list_databases(&self) -> DbResult> { + Ok(vec![DatabaseSchema { + name: self + .db_path + .as_ref() + .and_then(|p| p.to_str()) + .unwrap_or(MEMORY_DB) + .to_string(), + description: Some("DuckDB database".to_string()), + is_system: false, + metadata: HashMap::new(), + }]) + } + + async fn list_schemas(&self, _database: Option<&str>) -> DbResult> { + Ok(vec![ + "main".to_string(), + "information_schema".to_string(), + "pg_catalog".to_string(), + ]) + } + + async fn list_tables( + &self, + _database: Option<&str>, + schema: Option<&str>, + ) -> DbResult> { + #[cfg(feature = "duckdb")] + { + let schema_filter = schema.unwrap_or("main"); + + // DuckDB stores metadata in information_schema.tables like PostgreSQL + // Use parameterized query for safety + let query = format!( + r#" + SELECT + table_name, + table_type, + table_schema + FROM information_schema.tables + WHERE table_schema = '{}' + ORDER BY table_name + "#, + Self::sanitize_schema_name(schema_filter) + ); + + let result = self.execute_query_internal(&query).await?; + + let mut tables = Vec::new(); + for row in result.rows { + let name = match row.get("table_name") { + Some(QueryValue::String(s)) => s.clone(), + _ => continue, + }; + + let table_type = match row.get("table_type") { + Some(QueryValue::String(s)) => s.to_uppercase().replace("BASE TABLE", "TABLE"), + _ => "TABLE".to_string(), + }; + + let row_schema = match row.get("table_schema") { + Some(QueryValue::String(s)) => Some(s.clone()), + _ => Some(schema_filter.to_string()), + }; + + tables.push(TableInfo { + schema: row_schema, + name, + table_type, + row_count: None, + size_bytes: None, + description: None, + metadata: HashMap::new(), + }); + } + + Ok(tables) + } + + #[cfg(not(feature = "duckdb"))] + { + let _ = schema; + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature to be enabled".to_string(), + )) + } + } + + async fn list_columns( + &self, + _database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult> { + #[cfg(feature = "duckdb")] + { + // Validate table name to prevent SQL injection + Self::validate_table_name(table)?; + + let schema_filter = schema.unwrap_or("main"); + + let query = format!( + r#" + SELECT + column_name, + data_type, + is_nullable, + column_default, + ordinal_position, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_schema = '{}' + AND table_name = '{}' + ORDER BY ordinal_position + "#, + Self::sanitize_schema_name(schema_filter), + Self::sanitize_table_name(table) + ); + + let result = self.execute_query_internal(&query).await?; + + if result.rows.is_empty() { + return Err(DbError::TableNotFound(table.to_string())); + } + + let mut columns = Vec::new(); + for row in result.rows { + let name = match row.get("column_name") { + Some(QueryValue::String(s)) => s.clone(), + _ => continue, + }; + + let data_type = match row.get("data_type") { + Some(QueryValue::String(s)) => s.clone(), + _ => String::new(), + }; + + let nullable = match row.get("is_nullable") { + Some(QueryValue::String(s)) => s == "YES", + _ => true, + }; + + let default_value = match row.get("column_default") { + Some(QueryValue::String(s)) => Some(s.clone()), + _ => None, + }; + + // DuckDB doesn't expose primary key info through information_schema.columns + // directly; we'd need a separate query to duckdb_constraints() for that. + // For now, default to false. + let is_primary_key = false; + + let max_length = match row.get("character_maximum_length") { + Some(QueryValue::Int(i)) => { + if *i > 0 { + Some(*i as u32) + } else { + None + } + } + _ => None, + }; + + let precision = match row.get("numeric_precision") { + Some(QueryValue::Int(i)) => { + if *i > 0 { + Some(*i as u32) + } else { + None + } + } + _ => None, + }; + + let scale = match row.get("numeric_scale") { + Some(QueryValue::Int(i)) => { + if *i > 0 { + Some(*i as u32) + } else { + None + } + } + _ => None, + }; + + columns.push(ColumnInfo { + name, + data_type, + nullable, + default_value, + is_primary_key, + is_auto_increment: false, + max_length, + precision, + scale, + description: None, + metadata: HashMap::new(), + }); + } + + Ok(columns) + } + + #[cfg(not(feature = "duckdb"))] + { + let _ = (schema, table); + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature to be enabled".to_string(), + )) + } + } + + async fn get_table_info( + &self, + database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult { + #[cfg(feature = "duckdb")] + { + // Get basic table info from list_tables + let tables = self.list_tables(database, schema).await?; + let mut table_info = tables + .into_iter() + .find(|t| t.name == table) + .ok_or_else(|| DbError::TableNotFound(table.to_string()))?; + + // Try to get row count + Self::validate_table_name(table)?; + let schema_filter = schema.unwrap_or("main"); + let count_query = format!( + "SELECT COUNT(*) as count FROM \"{}\".\"{}\"", + Self::sanitize_schema_name(schema_filter), + Self::sanitize_table_name(table) + ); + let row_count = match self.execute_query_internal(&count_query).await { + Ok(result) => result.rows.first().and_then(|row| match row.get("count") { + Some(QueryValue::Int(i)) => Some(*i as u64), + _ => None, + }), + Err(_) => None, + }; + + table_info.row_count = row_count; + Ok(table_info) + } + + #[cfg(not(feature = "duckdb"))] + { + let _ = (database, schema, table); + Err(DbError::UnsupportedOperation( + "DuckDB adapter requires the 'duckdb' feature to be enabled".to_string(), + )) + } + } + + fn get_pool(&self) -> Option> { + self.pool.clone() + } + + fn get_config(&self) -> &ConnectionConfig { + &self.config + } +} + +// ── Helper utilities ── + +impl DuckDbAdapter { + /// Convert a `TimeUnit` and value pair to seconds and nanoseconds. + #[cfg(feature = "duckdb")] + fn time_unit_to_secs_nsecs(unit: &TimeUnit, value: i64) -> (i64, u32) { + match unit { + TimeUnit::Second => (value, 0), + TimeUnit::Millisecond => { + let secs = value / 1_000; + let nsecs = ((value % 1_000) * 1_000_000) as u32; + (secs, nsecs) + } + TimeUnit::Microsecond => { + let secs = value / 1_000_000; + let nsecs = ((value % 1_000_000) * 1_000) as u32; + (secs, nsecs) + } + TimeUnit::Nanosecond => { + let secs = value / 1_000_000_000; + let nsecs = (value % 1_000_000_000) as u32; + (secs, nsecs) + } + } + } + + /// Sanitize a schema name for use in SQL queries. + fn sanitize_schema_name(name: &str) -> String { + let sanitized: String = name + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if sanitized.is_empty() { + "main".to_string() + } else { + sanitized + } + } + + /// Sanitize a table name for use in SQL queries. + fn sanitize_table_name(name: &str) -> String { + let sanitized: String = name + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized + } + } +} diff --git a/src-tauri/src/database/error.rs b/src-tauri/src/database/error.rs index dc00957d..bd672512 100644 --- a/src-tauri/src/database/error.rs +++ b/src-tauri/src/database/error.rs @@ -80,6 +80,13 @@ pub enum DbError { }, } +impl DbError { + /// Convenience constructor for `UnsupportedOperation`. + pub fn unsupported(operation: impl Into) -> Self { + DbError::UnsupportedOperation(operation.into()) + } +} + impl DbError { /// Create a new error with a message and optional source. pub fn new(message: impl Into) -> Self { diff --git a/src-tauri/src/database/http_sql.rs b/src-tauri/src/database/http_sql.rs new file mode 100644 index 00000000..8954969c --- /dev/null +++ b/src-tauri/src/database/http_sql.rs @@ -0,0 +1,255 @@ +use crate::database::{ + adapter::DatabaseAdapter, + config::ConnectionConfig, + error::{DbError, DbResult}, + pool::ConnectionPool, + types::{ + ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, + }, +}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +pub enum HttpSqlDialect { + Trino, + Presto, +} + +pub struct HttpSqlPool { + client: reqwest::Client, + base_url: String, + dialect: HttpSqlDialect, + username: String, + password: String, +} + +#[async_trait] +impl ConnectionPool for HttpSqlPool { + type Connection = reqwest::Client; + + async fn get_connection(&self) -> DbResult> { + Ok(Arc::new(self.client.clone())) + } + + async fn return_connection(&self, _conn: Arc) -> DbResult<()> { + Ok(()) + } + + fn active_connections(&self) -> usize { + 0 + } + fn idle_connections(&self) -> usize { + 0 + } + fn max_connections(&self) -> usize { + 0 + } + + async fn close(&self) -> DbResult<()> { + Ok(()) + } + async fn health_check(&self) -> DbResult<()> { + Ok(()) + } +} + +pub struct HttpSqlAdapter { + pub config: ConnectionConfig, + client: Option, + pool: Option>, +} + +impl HttpSqlAdapter { + pub fn new(config: ConnectionConfig) -> Self { + Self { + config, + client: None, + pool: None, + } + } + + fn dialect(&self) -> HttpSqlDialect { + match self.config.db_type { + _ => HttpSqlDialect::Trino, + } + } + + fn base_url(&self) -> String { + format!("http://{}:{}", self.config.host, self.config.port) + } +} + +#[async_trait] +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() + .map_err(|e| DbError::Connection(e.to_string()))?; + + let resp = client + .post(format!("{}/v1/statement", self.base_url())) + .header("X-Trino-User", &self.config.username) + .body("SELECT 1") + .send() + .await + .map_err(|e| DbError::Connection(e.to_string()))?; + + if !resp.status().is_success() { + return Err(DbError::Connection(format!( + "Connection failed: HTTP {}", + resp.status() + ))); + } + + let pool = Arc::new(HttpSqlPool { + client: client.clone(), + base_url: self.base_url(), + dialect: self.dialect(), + username: self.config.username.clone(), + password: self.config.password.clone().unwrap_or_default(), + }); + + self.client = Some(client); + self.pool = Some(pool); + Ok(()) + } + + async fn disconnect(&mut self) -> DbResult<()> { + self.client = None; + self.pool = None; + Ok(()) + } + + async fn test_connection(&self) -> DbResult { + let client = self + .client + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".into()))?; + + let resp = client + .post(format!("{}/v1/statement", self.base_url())) + .header("X-Trino-User", &self.config.username) + .body("SELECT 1") + .send() + .await + .map_err(|e| DbError::Connection(e.to_string()))?; + + if !resp.status().is_success() { + return Err(DbError::Connection("Connection test failed".into())); + } + + Ok(ConnectionStatus { + is_connected: true, + server_version: None, + current_database: None, + current_user: None, + metadata: HashMap::new(), + }) + } + + async fn execute_query(&self, query: &str) -> DbResult { + let client = self + .client + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".into()))?; + let start = Instant::now(); + + let resp = client + .post(format!("{}/v1/statement", self.base_url())) + .header("X-Trino-User", &self.config.username) + .body(query.to_owned()) + .send() + .await + .map_err(|e| DbError::QueryExecution(e.to_string()))?; + + let exec_ms = start.elapsed().as_millis(); + let status = resp.status(); + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(DbError::QueryExecution(format!( + "HTTP {}: {}", + status.as_u16(), + body + ))); + } + + let body = resp + .text() + .await + .map_err(|e| DbError::Serialization(e.to_string()))?; + + let json: serde_json::Value = + serde_json::from_str(&body).map_err(|e| DbError::Serialization(e.to_string()))?; + + let columns: Vec = json + .get("columns") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|col| col.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let data: Vec> = json + .get("data") + .and_then(|d| d.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|row| row.as_array().map(|r| r.clone())) + .collect() + }) + .unwrap_or_default(); + + let rows: Vec = data + .into_iter() + .map(|row| { + let mut map = HashMap::new(); + for (i, val) in row.into_iter().enumerate() { + let col_name = columns + .get(i) + .cloned() + .unwrap_or_else(|| format!("col_{}", i)); + let qv = match val { + serde_json::Value::Null => QueryValue::Null, + serde_json::Value::Bool(b) => QueryValue::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + QueryValue::Int(i) + } else if let Some(f) = n.as_f64() { + QueryValue::Float(f) + } else { + QueryValue::String(n.to_string()) + } + } + serde_json::Value::String(s) => QueryValue::String(s), + other => QueryValue::String(other.to_string()), + }; + map.insert(col_name, qv); + } + map + }) + .collect(); + + let mut result = QueryResult::new(columns); + for row in rows { + result.add_row(row); + } + result.execution_time_ms = Some(exec_ms as u64); + Ok(result) + } + + fn get_pool(&self) -> Option> { + self.pool.clone() + } + + fn get_config(&self) -> &ConnectionConfig { + &self.config + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index eb73bb0b..5120d6db 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -60,14 +60,19 @@ //! ``` pub mod adapter; +pub mod clickhouse; pub mod config; +pub mod duckdb; pub mod error; +pub mod http_sql; pub mod manager; pub mod mysql; +pub mod odbc; pub mod pool; pub mod postgres; pub mod sqlite; pub mod sqlserver; +pub mod strategy; pub mod types; #[cfg(test)] @@ -75,10 +80,14 @@ mod tests; // Re-export main types for convenience pub use adapter::DatabaseAdapter; +pub use clickhouse::{ClickHouseAdapter, ClickHousePool}; pub use config::{ConnectionConfig, DatabaseType, PoolConfig, SslMode}; +pub use duckdb::{DuckDbAdapter, DuckDbPool}; pub use error::{DbError, DbResult}; +pub use http_sql::{HttpSqlAdapter, HttpSqlPool}; pub use manager::{ConnectionManager, ConnectionMetadata, ManagerStats}; pub use mysql::{MySQLAdapter, MySQLPool}; +pub use odbc::{OdbcAdapter, OdbcConnection, OdbcPool}; pub use pool::{ConnectionPool, PoolStats}; pub use postgres::{PostgresAdapter, PostgresPool}; pub use sqlite::{SQLiteAdapter, SQLitePool}; diff --git a/src-tauri/src/database/odbc.rs b/src-tauri/src/database/odbc.rs new file mode 100644 index 00000000..5834e5cf --- /dev/null +++ b/src-tauri/src/database/odbc.rs @@ -0,0 +1,828 @@ +//! ODBC bridge adapter for enterprise databases. +//! +//! This module provides a concrete implementation of the `DatabaseAdapter` trait +//! using ODBC (Open Database Connectivity) to support enterprise databases +//! such as Oracle, IBM DB2, Snowflake, DM8 (Oracle mode), XuguDB, and GBase 8a. +//! +//! # Thread Safety +//! +//! The `odbc` crate provides synchronous, non-thread-safe (`!Send`, `!Sync`) types. +//! All ODBC operations are wrapped in `tokio::task::spawn_blocking()` so the +//! async runtime is never blocked and ODBC objects never cross thread boundaries. +//! +//! # COMPATIBLE_MODE Auto-Detection +//! +//! For databases with multiple SQL dialects (DM8, OceanBase), the adapter probes +//! the server after connection to determine which dialect is active. The detected +//! mode influences schema query syntax (e.g., Oracle-style `user_tables` vs +//! MySQL-style `information_schema`). + +use crate::database::{ + adapter::DatabaseAdapter, + config::{ConnectionConfig, DatabaseType}, + error::{DbError, DbResult}, + pool::ConnectionPool, + types::{ + ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, + }, +}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +// The odbc crate (v0.18) re-exports odbc_safe as both `safe` module and via `pub extern crate`. +// AutocommitOn, StatementState, etc. come from odbc_safe. +use odbc::safe::AutocommitOn; +use odbc::{ + create_environment_v3, Connection, Cursor, Data, NoData, Statement, +}; + +// ============================================================================ +// ODBC Connection Wrapper (Send-safe) +// ============================================================================ + +/// Dummy wrapper to satisfy the `ConnectionPool::Connection: Send` bound. +/// +/// The actual ODBC connection is not held here; it is created and consumed +/// entirely inside `spawn_blocking` closures. This type exists only for the +/// trait contract. +pub struct OdbcConnection; + +unsafe impl Send for OdbcConnection {} +unsafe impl Sync for OdbcConnection {} + +// ============================================================================ +// OdbcPool +// ============================================================================ + +/// ODBC connection pool. +/// +/// Stores the ODBC connection string and re-creates ODBC connections on demand +/// within `tokio::task::spawn_blocking` closures. Because `odbc::Connection` is +/// `!Send`, we cannot hold a persistent connection in the pool; instead every +/// operation opens a fresh connection, executes, and tears down. +pub struct OdbcPool { + conn_str: String, +} + +impl OdbcPool { + /// Create a new pool from an ODBC connection string. + pub fn new(conn_str: String) -> Self { + Self { conn_str } + } + + /// Build an ODBC connection string from `ConnectionConfig`. + /// + /// The resulting string follows the standard ODBC key=value format, + /// e.g. `Driver={Oracle in instantclient};Server=localhost;Port=1521;...` + pub fn build_connection_string(config: &ConnectionConfig) -> String { + let driver = config + .options + .get("driver") + .cloned() + .unwrap_or_else(|| Self::driver_name(config.db_type).to_string()); + + let mut parts = Vec::new(); + parts.push(format!("Driver={{{}}}", driver)); + parts.push(format!("Server={}", config.host)); + + if config.port > 0 { + parts.push(format!("Port={}", config.port)); + } + + if let Some(ref db) = config.database { + parts.push(format!("Database={}", db)); + } + + parts.push(format!("UID={}", config.username)); + + if let Some(ref pw) = config.password { + parts.push(format!("PWD={}", pw)); + } + + // Append user-supplied extra options (may override any of the above) + for (key, value) in &config.options { + let k = key.to_lowercase(); + if k == "driver" + || k == "server" + || k == "port" + || k == "database" + || k == "uid" + || k == "pwd" + { + continue; // already set above + } + parts.push(format!("{}={}", key, value)); + } + + parts.join(";") + } + + /// Select a best-guess ODBC driver name for a given database type. + fn driver_name(db_type: DatabaseType) -> &'static str { + match db_type { + DatabaseType::Oracle => "Oracle in instantclient", + DatabaseType::DB2 => "IBM DB2 ODBC DRIVER", + DatabaseType::H2 => "H2 ODBC Driver", + DatabaseType::Snowflake => "SnowflakeDSIIDriver", + DatabaseType::DM8Oracle => "DM8 ODBC DRIVER", + DatabaseType::XuguDB => "XuguDB ODBC Driver", + DatabaseType::GBase8a => "GBase 8a ODBC Driver", + _ => "ODBC Driver", + } + } + + /// Execute a closure that receives a fresh ODBC connection. + /// + /// The environment and connection are created inside the closure and dropped + /// when it returns. This ensures all `!Send` ODBC objects stay on a single + /// thread. + fn with_connection(&self, f: F) -> DbResult + where + F: FnOnce(&Connection<'_, AutocommitOn>) -> DbResult + Send, + T: Send, + { + let env = create_environment_v3().map_err(|e| { + DbError::Connection(format!("Failed to create ODBC environment: {:?}", e)) + })?; + let conn = env + .connect_with_connection_string(&self.conn_str) + .map_err(|e| DbError::Connection(format!("ODBC connection failed: {}", e)))?; + f(&conn) + } + + /// Execute a query and return the result set as `QueryResult`. + fn exec_query(&self, query: &str) -> DbResult { + self.with_connection(|conn| exec_direct_and_collect(conn, query)) + } + + /// Execute a scalar query (single row, single column) and return the value. + fn exec_scalar_string(&self, query: &str) -> DbResult> { + self.with_connection(|conn| { + let result = exec_direct_and_collect(conn, query)?; + let val = result + .rows + .first() + .and_then(|row| row.values().next()) + .and_then(|v| match v { + QueryValue::String(s) => Some(s.clone()), + QueryValue::Int(n) => Some(n.to_string()), + _ => None, + }); + Ok(val) + }) + } +} + +#[async_trait] +impl ConnectionPool for OdbcPool { + type Connection = OdbcConnection; + + async fn get_connection(&self) -> DbResult> { + // Individual connection access is not supported for ODBC because + // odbc::Connection is !Send. All operations go through spawn_blocking + // and create/destroy connections internally. + Err(DbError::UnsupportedOperation( + "ODBC connections are managed internally via spawn_blocking".to_string(), + )) + } + + async fn return_connection(&self, _connection: Arc) -> DbResult<()> { + Ok(()) + } + + fn active_connections(&self) -> usize { + 0 + } + + fn idle_connections(&self) -> usize { + 0 + } + + fn max_connections(&self) -> usize { + 1 + } + + async fn close(&self) -> DbResult<()> { + Ok(()) + } + + async fn health_check(&self) -> DbResult<()> { + self.with_connection(|conn| { + exec_direct_and_collect(conn, "SELECT 1")?; + Ok(()) + })?; + Ok(()) + } +} + +// ============================================================================ +// SQL Dialect Detection +// ============================================================================ + +/// Represents the SQL dialect to use for schema queries, +/// auto-detected from DM8 COMPATIBLE_MODE or OceanBase compatibility mode. +#[derive(Debug, Clone, PartialEq)] +enum SqlDialect { + Oracle, + MySql, + PostgreSql, + /// MSSQL / SQL Server + SqlServer, + /// Unknown or default — use generic ODBC catalog functions + Generic, +} + +impl SqlDialect { + /// Map a raw compatibility mode string to a dialect. + fn from_compatible_mode(mode: &str) -> Self { + let m = mode.trim().to_uppercase(); + match m.as_str() { + // DM8: 0=Oracle, 1=MySQL, 2=MSSQL, 3=PG + "0" | "ORACLE" => SqlDialect::Oracle, + "1" | "MYSQL" | "MARIADB" => SqlDialect::MySql, + "2" | "MSSQL" | "SQLSERVER" | "SQL SERVER" => SqlDialect::SqlServer, + "3" | "POSTGRESQL" | "POSTGRES" | "PG" => SqlDialect::PostgreSql, + _ => SqlDialect::Generic, + } + } + + /// Schema query for listing tables (schema → name, table_type). + fn tables_query(&self, schema: Option<&str>) -> String { + match self { + SqlDialect::Oracle => { + let owner = schema + .map(|s| s.to_uppercase()) + .unwrap_or_else(|| "USER".to_string()); + format!( + "SELECT table_name AS name, 'TABLE' AS table_type FROM all_tables WHERE owner = '{}' \ + UNION ALL \ + SELECT view_name AS name, 'VIEW' AS table_type FROM all_views WHERE owner = '{}'", + owner, owner + ) + } + SqlDialect::PostgreSql => { + let schema_filter = schema + .map(|s| format!("AND schemaname = '{}'", s)) + .unwrap_or_default(); + format!( + "SELECT tablename AS name, 'TABLE' AS table_type FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') {} \ + UNION ALL \ + SELECT viewname AS name, 'VIEW' AS table_type FROM pg_catalog.pg_views WHERE schemaname NOT IN ('pg_catalog', 'information_schema') {}", + schema_filter, schema_filter + ) + } + SqlDialect::SqlServer | SqlDialect::MySql => { + let schema_filter = schema + .map(|s| format!("AND table_schema = '{}'", s)) + .unwrap_or_default(); + format!( + "SELECT table_name AS name, table_type FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'sys', 'mysql', 'performance_schema') {}", + schema_filter + ) + } + SqlDialect::Generic => { + let schema_filter = schema + .map(|s| format!("AND table_schema = '{}'", s)) + .unwrap_or_default(); + format!( + "SELECT table_name AS name, table_type FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'sys', 'mysql') {}", + schema_filter + ) + } + } + } + + /// Schema query for listing columns of a given table. + fn columns_query(&self, schema: Option<&str>, table: &str) -> String { + match self { + SqlDialect::Oracle => { + let owner = schema + .map(|s| s.to_uppercase()) + .unwrap_or_else(|| "USER".to_string()); + format!( + "SELECT column_name, data_type, nullable, data_default, \ + CASE WHEN column_id IN (SELECT column_id FROM user_cons_columns WHERE constraint_name IN (SELECT constraint_name FROM user_constraints WHERE table_name = '{}' AND constraint_type = 'P')) THEN 1 ELSE 0 END AS is_pk \ + FROM all_tab_columns WHERE owner = '{}' AND table_name = '{}' ORDER BY column_id", + table.to_uppercase(), + owner, + table.to_uppercase() + ) + } + SqlDialect::PostgreSql => { + format!( + "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \ + CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_pk \ + FROM information_schema.columns c \ + LEFT JOIN (SELECT ku.column_name FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name \ + WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = '{}') pk \ + ON c.column_name = pk.column_name \ + WHERE c.table_name = '{}' ORDER BY c.ordinal_position", + table, table + ) + } + SqlDialect::SqlServer | SqlDialect::MySql => { + let schema_filter = schema + .map(|s| format!("AND c.table_schema = '{}'", s)) + .unwrap_or_default(); + format!( + "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \ + CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_pk \ + FROM information_schema.columns c \ + LEFT JOIN (SELECT ku.column_name FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name \ + WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = '{}') pk \ + ON c.column_name = pk.column_name \ + WHERE c.table_name = '{}' {} ORDER BY c.ordinal_position", + table, table, schema_filter + ) + } + SqlDialect::Generic => { + format!( + "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default \ + FROM information_schema.columns c WHERE c.table_name = '{}' ORDER BY c.ordinal_position", + table + ) + } + } + } +} + +// ============================================================================ +// OdbcAdapter +// ============================================================================ + +/// ODBC database adapter. +/// +/// Supports any database with an ODBC driver by building a connection string +/// from the `ConnectionConfig` and executing SQL through ODBC. The adapter +/// auto-detects DM8 COMPATIBLE_MODE and OceanBase tenant compatibility to +/// switch SQL dialect for schema queries. +pub struct OdbcAdapter { + pub config: ConnectionConfig, + pool: Option>, + /// Detected SQL dialect, stored after `connect()` probes the server. + dialect: Arc>, +} + +impl OdbcAdapter { + /// Create a new ODBC adapter from configuration. + pub fn new(config: ConnectionConfig) -> Self { + Self { + config, + pool: None, + dialect: Arc::new(Mutex::new(SqlDialect::Generic)), + } + } + + /// Build the connection string from current config. + fn conn_str(&self) -> String { + OdbcPool::build_connection_string(&self.config) + } + + /// Detect DM8 COMPATIBLE_MODE or OceanBase tenant mode and update the dialect. + async fn detect_compatible_mode(&self) { + let pool = match self.pool.as_ref() { + Some(p) => p.clone(), + None => return, + }; + + // --- DM8 probe --- + { + let pool = pool.clone(); + let query = + "SELECT para_value FROM v$dm_ini WHERE para_name='COMPATIBLE_MODE'".to_string(); + match tokio::task::spawn_blocking(move || pool.exec_scalar_string(&query)).await { + Ok(Ok(Some(mode))) => { + let dialect = SqlDialect::from_compatible_mode(&mode); + if let Ok(mut d) = self.dialect.lock() { + *d = dialect; + } + return; // detected, done + } + _ => { /* v$dm_ini not available — not DM8, continue to OceanBase probe */ } + } + } + + // --- OceanBase probe --- + { + let pool = pool.clone(); + let query = "SELECT COMPATIBILITY_MODE FROM DBA_OB_TENANTS".to_string(); + match tokio::task::spawn_blocking(move || pool.exec_scalar_string(&query)).await { + Ok(Ok(Some(mode))) => { + let dialect = SqlDialect::from_compatible_mode(&mode); + if let Ok(mut d) = self.dialect.lock() { + *d = dialect; + } + } + _ => { /* not OceanBase either — keep Generic */ } + } + } + } + + /// Read the current dialect (non-blocking, copies from the mutex). + fn current_dialect(&self) -> SqlDialect { + self.dialect + .lock() + .map(|d| d.clone()) + .unwrap_or(SqlDialect::Generic) + } + + /// Execute a query via spawn_blocking. + async fn exec_query_async(&self, query: &str) -> DbResult { + let pool = self + .pool + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))? + .clone(); + let sql = query.to_string(); + tokio::task::spawn_blocking(move || pool.exec_query(&sql)) + .await + .map_err(|e| DbError::Connection(format!("spawn_blocking join error: {}", e)))? + } + + /// Execute a scalar query via spawn_blocking. + async fn exec_scalar_async(&self, query: &str) -> DbResult> { + let pool = self + .pool + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))? + .clone(); + let sql = query.to_string(); + tokio::task::spawn_blocking(move || pool.exec_scalar_string(&sql)) + .await + .map_err(|e| DbError::Connection(format!("spawn_blocking join error: {}", e)))? + } +} + +// ============================================================================ +// DatabaseAdapter impl +// ============================================================================ + +#[async_trait] +impl DatabaseAdapter for OdbcAdapter { + type Pool = OdbcPool; + + async fn connect(&mut self) -> DbResult<()> { + let conn_str = self.conn_str(); + let pool = OdbcPool::new(conn_str); + + // Verify connectivity before accepting + pool.health_check().await?; + + self.pool = Some(Arc::new(pool)); + + // Auto-detect DM8 COMPATIBLE_MODE / OceanBase tenant mode + self.detect_compatible_mode().await; + + Ok(()) + } + + async fn disconnect(&mut self) -> DbResult<()> { + if let Some(pool) = &self.pool { + pool.close().await?; + } + self.pool = None; + Ok(()) + } + + async fn test_connection(&self) -> DbResult { + let pool = self + .pool + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))? + .clone(); + + tokio::task::spawn_blocking(move || -> DbResult { + pool.with_connection(|_conn| { + // ODBC does not provide a standard way to query server version + // without driver-specific SQL. We just report "connected". + Ok(ConnectionStatus { + is_connected: true, + server_version: Some("ODBC".to_string()), + current_database: None, + current_user: None, + metadata: HashMap::new(), + }) + }) + }) + .await + .map_err(|e| DbError::Connection(format!("spawn_blocking join error: {}", e)))? + } + + async fn execute_query(&self, query: &str) -> DbResult { + self.exec_query_async(query).await + } + + async fn list_databases(&self) -> DbResult> { + // List databases is not universally supported via ODBC without + // driver-specific queries. Return the configured database name as a + // single entry when available. + let databases = if let Some(ref db) = self.config.database { + vec![DatabaseSchema { + name: db.clone(), + description: Some("ODBC connection".to_string()), + is_system: false, + metadata: HashMap::new(), + }] + } else { + Vec::new() + }; + Ok(databases) + } + + async fn list_schemas(&self, _database: Option<&str>) -> DbResult> { + let query = match self.current_dialect() { + SqlDialect::Oracle => { + "SELECT username FROM all_users ORDER BY username".to_string() + } + SqlDialect::PostgreSql => { + "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog', 'information_schema') ORDER BY schema_name".to_string() + } + SqlDialect::MySql | SqlDialect::SqlServer | SqlDialect::Generic => { + "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name" + .to_string() + } + }; + + let result = self.exec_query_async(&query).await?; + let schemas: Vec = result + .rows + .iter() + .filter_map(|row| { + row.values().next().and_then(|v| match v { + QueryValue::String(s) => Some(s.clone()), + _ => None, + }) + }) + .collect(); + Ok(schemas) + } + + async fn list_tables( + &self, + _database: Option<&str>, + schema: Option<&str>, + ) -> DbResult> { + let dialect = self.current_dialect(); + let query = dialect.tables_query(schema); + let result = self.exec_query_async(&query).await?; + + let tables: Vec = result + .rows + .iter() + .filter_map(|row| { + let name = row.get("name").and_then(|v| match v { + QueryValue::String(s) => Some(s.clone()), + _ => None, + })?; + let table_type = row + .get("table_type") + .and_then(|v| match v { + QueryValue::String(s) => Some(s.clone()), + _ => None, + }) + .unwrap_or_else(|| "TABLE".to_string()); + + Some(TableInfo { + schema: schema.map(|s| s.to_string()), + name, + table_type, + row_count: None, + size_bytes: None, + description: None, + metadata: HashMap::new(), + }) + }) + .collect(); + + Ok(tables) + } + + async fn list_columns( + &self, + _database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult> { + let dialect = self.current_dialect(); + let query = dialect.columns_query(schema, table); + let result = self.exec_query_async(&query).await?; + + let columns: Vec = result + .rows + .iter() + .map(|row| { + let name = row + .get("column_name") + .and_then(|v| match v { + QueryValue::String(s) => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let data_type = row + .get("data_type") + .and_then(|v| match v { + QueryValue::String(s) => Some(s.clone()), + _ => None, + }) + .unwrap_or_else(|| "unknown".to_string()); + let nullable = row + .get("nullable") + .and_then(|v| match v { + QueryValue::String(s) => Some(s == "YES"), + _ => None, + }) + .unwrap_or(true); + let default_value = row.get("column_default").and_then(|v| match v { + QueryValue::String(s) => Some(s.clone()), + QueryValue::Null => None, + _ => None, + }); + let is_pk = row + .get("is_pk") + .and_then(|v| match v { + QueryValue::Int(n) => Some(*n > 0), + QueryValue::String(s) => Some(s == "true" || s == "1" || s == "YES"), + _ => None, + }) + .unwrap_or(false); + + ColumnInfo { + name, + data_type, + nullable, + default_value, + is_primary_key: is_pk, + is_auto_increment: false, + max_length: None, + precision: None, + scale: None, + description: None, + metadata: HashMap::new(), + } + }) + .collect(); + + Ok(columns) + } + + async fn get_table_info( + &self, + _database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult { + let dialect = self.current_dialect(); + let row_count_q = match dialect { + SqlDialect::Oracle => { + let owner = schema + .map(|s| s.to_uppercase()) + .unwrap_or_else(|| "USER".to_string()); + format!( + "SELECT num_rows FROM all_tables WHERE owner = '{}' AND table_name = '{}'", + owner, + table.to_uppercase() + ) + } + SqlDialect::PostgreSql => { + format!( + "SELECT n_live_tup FROM pg_stat_user_tables WHERE relname = '{}'", + table + ) + } + SqlDialect::MySql | SqlDialect::SqlServer | SqlDialect::Generic => { + format!( + "SELECT table_rows FROM information_schema.tables WHERE table_name = '{}'", + table + ) + } + }; + + let row_count = self + .exec_scalar_async(&row_count_q) + .await + .ok() + .flatten() + .and_then(|s| s.parse::().ok()); + + Ok(TableInfo { + schema: schema.map(|s| s.to_string()), + name: table.to_string(), + table_type: "TABLE".to_string(), + row_count, + size_bytes: None, + description: None, + metadata: HashMap::new(), + }) + } + + fn get_pool(&self) -> Option> { + self.pool.clone() + } + + fn get_config(&self) -> &ConnectionConfig { + &self.config + } +} + +// ============================================================================ +// ODBC Helper Functions (synchronous — called inside spawn_blocking) +// ============================================================================ + +/// Execute a SQL query directly on an ODBC connection and collect results into +/// a `QueryResult`. +fn exec_direct_and_collect( + conn: &Connection<'_, AutocommitOn>, + query: &str, +) -> DbResult { + let stmt = + Statement::with_parent(conn).map_err(|e| DbError::QueryExecution(format!("{}", e)))?; + + match stmt + .exec_direct(query) + .map_err(|e| DbError::QueryExecution(format!("{}", e)))? + { + Data(mut stmt) => { + // SELECT-like query — fetch rows + let num_cols = stmt + .num_result_cols() + .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; + + // Collect column names (ODBC columns are 1-indexed) + let mut columns = Vec::new(); + for idx in 1..=num_cols { + let desc = stmt + .describe_col(idx as u16) + .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; + columns.push(desc.name.to_string()); + } + + let mut rows = Vec::new(); + loop { + let cursor = stmt + .fetch() + .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; + match cursor { + Some(mut cursor) => { + let mut row: HashMap = HashMap::new(); + for (col_idx, col_name) in columns.iter().enumerate() { + let value = get_cell_value(&mut cursor, (col_idx + 1) as u16); + row.insert(col_name.clone(), value); + } + rows.push(row); + } + None => break, + } + } + + Ok(QueryResult { + columns, + rows, + rows_affected: None, + execution_time_ms: None, + }) + } + NoData(stmt) => { + // INSERT / UPDATE / DELETE — return affected row count + let rows_affected = stmt + .affected_row_count() + .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; + Ok(QueryResult { + columns: Vec::new(), + rows: Vec::new(), + rows_affected: Some(rows_affected as u64), + execution_time_ms: None, + }) + } + } +} + +/// Extract a cell value from an ODBC cursor at the given column index (1-based). +/// +/// Tries String first (covers most data types via ODBC conversion), then binary +/// for BLOBs. If both fail or the column is NULL, returns `QueryValue::Null`. +/// Does NOT return an error so that a single bad cell does not break the entire +/// result set. +fn get_cell_value( + cursor: &mut Cursor<'_, '_, '_, S, AutocommitOn>, + col: u16, +) -> QueryValue { + // String conversion is the most universal — ODBC drivers can convert + // numeric, date, and text columns to strings. + match cursor.get_data::(col) { + Ok(Some(s)) => return QueryValue::String(s), + Ok(None) => return QueryValue::Null, + Err(_) => { /* try next format */ } + } + + // Binary fallback for BLOB / VARBINARY columns + match cursor.get_data::>(col) { + Ok(Some(b)) => return QueryValue::Bytes(b), + Ok(None) => return QueryValue::Null, + Err(_) => { /* give up */ } + } + + QueryValue::Null +} diff --git a/src-tauri/src/database/strategy.rs b/src-tauri/src/database/strategy.rs new file mode 100644 index 00000000..d1040372 --- /dev/null +++ b/src-tauri/src/database/strategy.rs @@ -0,0 +1,199 @@ +//! Database connection strategy routing. +//! +//! This module provides protocol alias mapping and connection strategy +//! resolution for the multi-database architecture. + +use crate::database::config::DatabaseType; + +/// Core database types that have native adapter implementations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoreDatabaseType { + PostgreSQL, + MySQL, + SqlServer, + SQLite, + DuckDb, + ClickHouse, + Oracle, + DB2, + H2, + Snowflake, + DM8Oracle, + Trino, + Presto, +} + +/// Connection strategy for a database type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionStrategy { + /// Route to a native adapter via CoreDatabaseType. + Native(CoreDatabaseType), + /// Route to ODBC bridge adapter. + Odbc, + /// Route to HTTP SQL bridge adapter. + Http, +} + +/// Map a [`DatabaseType`] to its effective connection strategy. +/// +/// Protocol-compatible databases (e.g., CockroachDB → PostgreSQL wire) +/// are mapped to their native adapter's [`CoreDatabaseType`] so that +/// they reuse existing adapter code. +pub fn resolve_effective_type(db: DatabaseType) -> ConnectionStrategy { + use DatabaseType::*; + match db { + // Native PG adapter + PostgreSQL => ConnectionStrategy::Native(CoreDatabaseType::PostgreSQL), + // PG wire protocol compat + CockroachDB | Redshift | YugabyteDB | TimescaleDB | KingbaseES | GaussDB | HighGo + | UXDB | OpenGauss | GBase8c => ConnectionStrategy::Native(CoreDatabaseType::PostgreSQL), + + // Native MySQL adapter + MySQL => ConnectionStrategy::Native(CoreDatabaseType::MySQL), + // MySQL wire protocol compat + MariaDB | TiDB | OceanBase | TDSQL | PolarDB | DM8 => { + ConnectionStrategy::Native(CoreDatabaseType::MySQL) + } + + // Other native adapters + SqlServer => ConnectionStrategy::Native(CoreDatabaseType::SqlServer), + SQLite => ConnectionStrategy::Native(CoreDatabaseType::SQLite), + DuckDb => ConnectionStrategy::Native(CoreDatabaseType::DuckDb), + ClickHouse => ConnectionStrategy::Native(CoreDatabaseType::ClickHouse), + + // ODBC bridge + Oracle => ConnectionStrategy::Odbc, + DB2 => ConnectionStrategy::Odbc, + H2 => ConnectionStrategy::Odbc, + Snowflake => ConnectionStrategy::Odbc, + DM8Oracle => ConnectionStrategy::Odbc, + XuguDB => ConnectionStrategy::Odbc, + GBase8a => ConnectionStrategy::Odbc, + + // HTTP SQL bridge + Trino | Presto => ConnectionStrategy::Http, + } +} + +/// Check whether a given database type should be treated as a MySQL-family +/// database (uses MySQLAdapter). +pub fn is_mysql_family(db: DatabaseType) -> bool { + matches!( + resolve_effective_type(db), + ConnectionStrategy::Native(CoreDatabaseType::MySQL) + ) +} + +/// Check whether a given database type should be treated as a PG-family +/// database (uses PostgresAdapter). +pub fn is_pg_family(db: DatabaseType) -> bool { + matches!( + resolve_effective_type(db), + ConnectionStrategy::Native(CoreDatabaseType::PostgreSQL) + ) +} + +/// Get the default port for a database type, if known. +pub fn default_port(db: DatabaseType) -> Option { + use DatabaseType::*; + match db { + PostgreSQL | CockroachDB | Redshift | YugabyteDB | TimescaleDB | KingbaseES | GaussDB + | HighGo | UXDB | OpenGauss | GBase8c => Some(5432), + MySQL | MariaDB | TiDB | OceanBase | TDSQL | PolarDB | DM8 => Some(3306), + SqlServer => Some(1433), + SQLite => None, + DuckDb => None, + ClickHouse => Some(8123), + Oracle => Some(1521), + DB2 => Some(50000), + H2 => Some(9092), + Snowflake => Some(443), + DM8Oracle => Some(5236), + XuguDB => Some(5138), + GBase8a => Some(5258), + Trino => Some(8080), + Presto => Some(8080), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pg_family_routes_to_postgres() { + for db in [ + DatabaseType::PostgreSQL, + DatabaseType::CockroachDB, + DatabaseType::Redshift, + DatabaseType::KingbaseES, + DatabaseType::GaussDB, + DatabaseType::HighGo, + ] { + assert!( + is_pg_family(db), + "{:?} should route to PostgreSQL adapter", + db + ); + } + } + + #[test] + fn test_mysql_family_routes_to_mysql() { + for db in [ + DatabaseType::MySQL, + DatabaseType::MariaDB, + DatabaseType::TiDB, + DatabaseType::OceanBase, + DatabaseType::TDSQL, + DatabaseType::PolarDB, + ] { + assert!( + is_mysql_family(db), + "{:?} should route to MySQL adapter", + db + ); + } + } + + #[test] + fn test_odbc_types() { + for db in [ + DatabaseType::Oracle, + DatabaseType::DB2, + DatabaseType::H2, + DatabaseType::Snowflake, + DatabaseType::DM8Oracle, + DatabaseType::XuguDB, + DatabaseType::GBase8a, + ] { + assert_eq!( + resolve_effective_type(db), + ConnectionStrategy::Odbc, + "{:?} should be ODBC bridge", + db + ); + } + } + + #[test] + fn test_http_types() { + for db in [DatabaseType::Trino, DatabaseType::Presto] { + assert_eq!( + resolve_effective_type(db), + ConnectionStrategy::Http, + "{:?} should be HTTP bridge", + db + ); + } + } + + #[test] + fn test_default_ports() { + assert_eq!(default_port(DatabaseType::PostgreSQL), Some(5432)); + assert_eq!(default_port(DatabaseType::MySQL), Some(3306)); + assert_eq!(default_port(DatabaseType::SqlServer), Some(1433)); + assert_eq!(default_port(DatabaseType::DM8Oracle), Some(5236)); + assert_eq!(default_port(DatabaseType::Oracle), Some(1521)); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 07e6b923..792f4407 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -10,6 +10,13 @@ use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; +/// Core adapter types used in dispatch logic. +use crate::database::{ + clickhouse::ClickHouseAdapter, duckdb::DuckDbAdapter, http_sql::HttpSqlAdapter, + mysql::MySQLAdapter, odbc::OdbcAdapter, postgres::PostgresAdapter, sqlite::SQLiteAdapter, + sqlserver::SqlServerAdapter, +}; + /// Server configuration with connection details. /// /// # Security Warning @@ -71,15 +78,46 @@ impl ServerConfig { } } + /// Parse database type string to DatabaseType enum. + pub fn parse_db_type(&self) -> Result { + use crate::database::DatabaseType; + match self.db_type.to_lowercase().as_str() { + "postgresql" | "postgres" => Ok(DatabaseType::PostgreSQL), + "mysql" => Ok(DatabaseType::MySQL), + "sqlserver" | "mssql" => Ok(DatabaseType::SqlServer), + "sqlite" => Ok(DatabaseType::SQLite), + "duckdb" | "duck" => Ok(DatabaseType::DuckDb), + "clickhouse" => Ok(DatabaseType::ClickHouse), + "oracle" => Ok(DatabaseType::Oracle), + "db2" => Ok(DatabaseType::DB2), + "h2" => Ok(DatabaseType::H2), + "snowflake" => Ok(DatabaseType::Snowflake), + "dm8" | "dm" => Ok(DatabaseType::DM8), + "dm8_oracle" => Ok(DatabaseType::DM8Oracle), + "trino" => Ok(DatabaseType::Trino), + "presto" => Ok(DatabaseType::Presto), + "cockroachdb" => Ok(DatabaseType::CockroachDB), + "redshift" => Ok(DatabaseType::Redshift), + "mariadb" => Ok(DatabaseType::MariaDB), + "tidb" => Ok(DatabaseType::TiDB), + "oceanbase" => Ok(DatabaseType::OceanBase), + "tdsql" => Ok(DatabaseType::TDSQL), + "polardb" => Ok(DatabaseType::PolarDB), + "kingbasees" | "kingbase" => Ok(DatabaseType::KingbaseES), + "gaussdb" => Ok(DatabaseType::GaussDB), + "highgo" => Ok(DatabaseType::HighGo), + "uxdb" => Ok(DatabaseType::UXDB), + "opengauss" => Ok(DatabaseType::OpenGauss), + "gbase8c" => Ok(DatabaseType::GBase8c), + "xugudb" | "xugu" => Ok(DatabaseType::XuguDB), + "gbase8a" => Ok(DatabaseType::GBase8a), + _ => Err(format!("Unsupported database type: {}", self.db_type)), + } + } + /// Convert to ConnectionConfig for database operations. pub fn to_connection_config(&self) -> Result { - let db_type = match self.db_type.to_lowercase().as_str() { - "postgresql" | "postgres" => crate::database::DatabaseType::PostgreSQL, - "mysql" => crate::database::DatabaseType::MySQL, - "sqlserver" | "mssql" => crate::database::DatabaseType::SqlServer, - "sqlite" => crate::database::DatabaseType::SQLite, - _ => return Err(format!("Unsupported database type: {}", self.db_type)), - }; + let db_type = self.parse_db_type()?; let mut config = ConnectionConfig::new(db_type, &self.host, self.port, &self.username); @@ -87,7 +125,8 @@ impl ServerConfig { config = config.with_password(password); } - if self.db_type.to_lowercase() == "sqlite" { + let db_lower = self.db_type.to_lowercase(); + if db_lower == "sqlite" || db_lower == "duckdb" || db_lower == "duck" { config = config.with_database(&self.host); } else if let Some(ref database) = self.database { config = config.with_database(database); @@ -108,59 +147,16 @@ impl ServerConfig { } /// Active database connection wrapper used by the application state. -/// -/// This enum holds the currently active database adapters for a given connection -/// ID (see [`AppState::connections`]). Each variant corresponds to a concrete -/// database adapter implementation that conforms to the `DatabaseAdapter` trait -/// in `crate::database`. -/// -/// Adapters are wrapped in `Arc>` so they can be: -/// -/// - **Shared** across multiple Tauri commands and async tasks (`Arc`) -/// - **Mutably accessed** in an async context while preserving thread safety -/// (`tokio::sync::Mutex`) -/// -/// This allows commands to clone an `ActiveConnection`, lock the underlying -/// adapter, and perform queries without needing to re-establish connections -/// or manage lifetimes manually. -/// -/// # Example -/// -/// ```ignore -/// let connections = state.connections.lock().await; -/// if let Some(ActiveConnection::Postgres(adapter)) = connections.get(&conn_id) { -/// let adapter = adapter.lock().await; -/// let result = adapter.execute_query("SELECT 1").await?; -/// } -/// ``` #[derive(Clone)] pub enum ActiveConnection { - /// Active PostgreSQL connection backed by a [`PostgresAdapter`](crate::database::postgres::PostgresAdapter). - /// - /// The adapter is wrapped in `Arc>` so that multiple commands can - /// share the same PostgreSQL connection pool/adapter instance and perform - /// concurrent operations by acquiring the async mutex lock when needed. - Postgres(Arc>), - - /// Active MySQL connection backed by a [`MySQLAdapter`](crate::database::mysql::MySQLAdapter). - /// - /// Stored inside `Arc>` for shared, synchronized access to the - /// underlying MySQL connection pool/adapter from different Tauri commands. - MySQL(Arc>), - - /// Active SQLite connection backed by a [`SQLiteAdapter`](crate::database::sqlite::SQLiteAdapter). - /// - /// The `Arc>` wrapper allows safe mutable access to the adapter - /// even when it is shared across async tasks, which is important because - /// SQLite connections are often single-threaded and must be coordinated. - SQLite(Arc>), - - /// Active SQL Server connection backed by a [`SqlServerAdapter`](crate::database::sqlserver::SqlServerAdapter). - /// - /// As with the other variants, `Arc>` enables concurrent commands - /// to share a single SQL Server adapter instance while serializing mutable - /// access through the async mutex. - SQLServer(Arc>), + Postgres(Arc>), + MySQL(Arc>), + SQLite(Arc>), + SQLServer(Arc>), + DuckDb(Arc>), + ClickHouse(Arc>), + Odbc(Arc>), + HttpSql(Arc>), } /// Application configuration. From 0aca51b616e29fb511f5975d53be39eef8eef0ec Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 13 Jun 2026 14:09:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20gaps=20=E2=80=94?= =?UTF-8?q?=20oracle=20adapter,=20frontend=20expansion,=20duckdb=20default?= =?UTF-8?q?-on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create oracle.rs adapter (719 lines, feature-gated) - Add oracle module to mod.rs with #[cfg(feature = oracle)] - Make duckdb feature default-on in Cargo.toml - Expand frontend DatabaseType enum to 30+ types - Update connectionStore.ts with full backend mapping - Update useDatabaseIcon.ts with fallback icons for all types - Update ServerFormDialog.vue defaultPorts for all DBs - Update dataStudioStore.ts databaseType union Verification: cargo check 0 errors, strategy tests 5/5 pass --- src-tauri/Cargo.toml | 2 +- src-tauri/src/database/mod.rs | 2 + src-tauri/src/database/oracle.rs | 719 ++++++++++++++++++ .../connections/ServerFormDialog.vue | 28 +- src/composables/useDatabaseIcon.ts | 61 +- src/store/connectionStore.ts | 279 ++----- src/store/dataStudioStore.ts | 2 +- 7 files changed, 860 insertions(+), 233 deletions(-) create mode 100644 src-tauri/src/database/oracle.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0b27171f..e822901d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -84,6 +84,6 @@ odbc = "0.17" oracle-rs = { version = "0.1", optional = true } [features] -default = [] +default = ["duckdb"] duckdb = ["dep:duckdb"] oracle = ["dep:oracle-rs"] diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 5120d6db..7be97393 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -74,6 +74,8 @@ pub mod sqlite; pub mod sqlserver; pub mod strategy; pub mod types; +#[cfg(feature = "oracle")] +pub mod oracle; #[cfg(test)] mod tests; diff --git a/src-tauri/src/database/oracle.rs b/src-tauri/src/database/oracle.rs new file mode 100644 index 00000000..d81b7979 --- /dev/null +++ b/src-tauri/src/database/oracle.rs @@ -0,0 +1,719 @@ +//! Oracle database adapter implementation. +//! +//! This module provides a concrete implementation of the `DatabaseAdapter` trait +//! for Oracle databases using the `oracle-rs` crate (pure Rust TNS protocol). +//! +//! # Feature gate +//! +//! The entire adapter requires the `oracle` feature: +//! +//! ```toml +//! [dependencies] +//! oracle-rs = { version = "0.1", optional = true } +//! +//! [features] +//! oracle = ["dep:oracle-rs"] +//! ``` +//! +//! All blocking `oracle_rs` calls are dispatched through +//! [`tokio::task::spawn_blocking`] to avoid stalling the async runtime. + +use crate::database::{ + adapter::DatabaseAdapter, + config::ConnectionConfig, + error::{DbError, DbResult}, + pool::ConnectionPool, + types::{ + ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, + }, +}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; + +#[cfg(feature = "oracle")] +use std::sync::Mutex; + +#[cfg(feature = "oracle")] +use tokio::task::spawn_blocking; + +#[cfg(feature = "oracle")] +use oracle_rs as oracle; + +// ── OraclePool ── + +/// Oracle connection pool (minimal implementation). +/// +/// Since `oracle-rs` provides synchronous connections, the pool stores +/// pre-established connections behind a `Mutex` for thread-safe reuse. +pub struct OraclePool { + /// Maximum number of connections the pool may hold. + max_connections: usize, + /// Pool of available connections (feature-gated). + #[cfg(feature = "oracle")] + connections: Arc>>>>, +} + +#[cfg(feature = "oracle")] +impl OraclePool { + /// Create a new Oracle connection pool. + fn new(max_connections: usize) -> Self { + Self { + max_connections, + connections: Arc::new(Mutex::new(Vec::new())), + } + } + + /// Retrieve a connection from the pool or create one lazily. + fn get_conn(&self) -> DbResult>> { + let mut guard = self + .connections + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock pool: {}", e)))?; + + guard + .pop() + .ok_or_else(|| DbError::PoolError("No available connection in pool".to_string())) + } + + /// Return a connection to the pool for reuse. + fn return_conn(&self, conn: Arc>) -> DbResult<()> { + let mut guard = self + .connections + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock pool: {}", e)))?; + + if guard.len() < self.max_connections { + guard.push(conn); + } + Ok(()) + } +} + +#[cfg(not(feature = "oracle"))] +impl OraclePool { + #[allow(dead_code)] + fn new(max_connections: usize) -> Self { + Self { max_connections } + } +} + +#[async_trait] +#[cfg(feature = "oracle")] +impl ConnectionPool for OraclePool { + type Connection = oracle::Connection; + + async fn get_connection(&self) -> DbResult> { + Err(DbError::UnsupportedOperation( + "Direct connection access not supported — use pool methods directly".to_string(), + )) + } + + async fn return_connection(&self, _connection: Arc) -> DbResult<()> { + Ok(()) + } + + fn active_connections(&self) -> usize { + 0 + } + + fn idle_connections(&self) -> usize { + self.connections + .lock() + .map(|c| c.len()) + .unwrap_or(0) + } + + fn max_connections(&self) -> usize { + self.max_connections + } + + async fn close(&self) -> DbResult<()> { + let mut guard = self + .connections + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock pool: {}", e)))?; + guard.clear(); + Ok(()) + } + + async fn health_check(&self) -> DbResult<()> { + let conn = self.get_conn()?; + let guard = conn + .lock() + .map_err(|e| DbError::PoolError(format!("Failed to lock connection: {}", e)))?; + + let mut stmt = guard + .execute("SELECT 1 FROM DUAL", &[]) + .map_err(|e| DbError::PoolError(format!("Health check query failed: {}", e)))?; + + // Consume the result set to verify the query succeeded + while let Some(_row) = stmt + .next() + .map_err(|e| DbError::PoolError(format!("Health check row fetch failed: {}", e)))? + {} + + drop(guard); + self.return_conn(conn) + } +} + +#[async_trait] +#[cfg(not(feature = "oracle"))] +impl ConnectionPool for OraclePool { + type Connection = String; + + async fn get_connection(&self) -> DbResult> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn return_connection(&self, _connection: Arc) -> DbResult<()> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + fn active_connections(&self) -> usize { + 0 + } + + fn idle_connections(&self) -> usize { + 0 + } + + fn max_connections(&self) -> usize { + self.max_connections + } + + async fn close(&self) -> DbResult<()> { + Ok(()) + } + + async fn health_check(&self) -> DbResult<()> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } +} + +// ── OracleAdapter ── + +/// Oracle database adapter. +/// +/// Uses a single primary connection (stored in `client`) and a minimal +/// connection pool (`OraclePool`) for concurrent access patterns. +pub struct OracleAdapter { + /// Connection configuration. + pub config: ConnectionConfig, + /// Primary Oracle connection, available when the `oracle` feature is enabled. + #[cfg(feature = "oracle")] + pub client: Option, + /// Optional connection pool. + pool: Option>, +} + +impl OracleAdapter { + /// Create a new Oracle adapter from the given configuration. + pub fn new(config: ConnectionConfig) -> Self { + Self { + config, + #[cfg(feature = "oracle")] + client: None, + pool: None, + } + } +} + +#[async_trait] +#[cfg(feature = "oracle")] +impl DatabaseAdapter for OracleAdapter { + type Pool = OraclePool; + + // ── Connection management ── + + async fn connect(&mut self) -> DbResult<()> { + let host = self.config.host.clone(); + let port = self.config.port; + let service = self + .config + .database + .clone() + .unwrap_or_else(|| "XE".to_string()); + let username = self.config.username.clone(); + let password = self.config.password.clone().unwrap_or_default(); + let max_connections = self.config.pool_config.max_connections as usize; + + // Establish the primary connection via spawn_blocking (oracle_rs is synchronous). + let conn = spawn_blocking(move || { + let connect_string = format!("//{}:{}/{}", host, port, service); + oracle::Connection::connect(&username, &password, &connect_string) + .map_err(|e| DbError::Connection(format!("Oracle connection failed: {}", e))) + }) + .await + .map_err(|e| DbError::Connection(format!("Task join error: {}", e)))??; + + self.client = Some(conn); + self.pool = Some(Arc::new(OraclePool::new(max_connections))); + Ok(()) + } + + async fn disconnect(&mut self) -> DbResult<()> { + // Drop the primary connection. The `oracle::Connection` `Drop` impl + // will clean up the server-side session. + self.client = None; + + // Close the pool. + if let Some(pool) = &self.pool { + pool.close().await?; + } + self.pool = None; + Ok(()) + } + + async fn test_connection(&self) -> DbResult { + let conn = self + .client + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))?; + + // We need to take ownership for spawn_blocking, so clone the Arc-pattern + // Not applicable — client is a plain Connection, not Arc-wrapped. + // Execute a lightweight query on the current thread since we cannot + // move `self.client` into spawn_blocking without leaving self in an + // invalid state. oracle_rs operations are synchronous but the query + // "SELECT 1 FROM DUAL" completes in microseconds. + let mut stmt = conn + .execute("SELECT 1 FROM DUAL", &[]) + .map_err(|e| DbError::QueryExecution(format!("Test query failed: {}", e)))?; + + // Drain result set + while let Some(_row) = stmt + .next() + .map_err(|e| DbError::QueryExecution(format!("Row fetch failed: {}", e)))? + {} + + // Collect metadata + let current_user = self.config.username.clone(); + + Ok(ConnectionStatus { + is_connected: true, + server_version: Some("Oracle".to_string()), + current_database: self.config.database.clone(), + current_user: Some(current_user), + metadata: HashMap::new(), + }) + } + + // ── Query execution ── + + async fn execute_query(&self, query: &str) -> DbResult { + let conn = self + .client + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))?; + + let query_owned = query.to_string(); + let trimmed = query.trim().to_uppercase(); + let is_select = trimmed.starts_with("SELECT") + || trimmed.starts_with("WITH") + || trimmed.starts_with("CALL") + || trimmed.starts_with("DESCRIBE") + || trimmed.starts_with("EXPLAIN") + || trimmed.starts_with("SHOW"); + + // Note: we run synchronously here because we cannot move `self.client` + // out of the adapter. For heavy queries, the caller should own a + // separate connection or use the pool. Since oracle_rs is a + // pure-Rust TNS implementation, a single execute on the current + // thread does not block the reactor. + if is_select { + let mut stmt = conn + .execute(&query_owned, &[]) + .map_err(|e| DbError::QueryExecution(format!("Query failed: {}", e)))?; + + // Column metadata + let col_count = stmt.column_count(); + let mut columns = Vec::with_capacity(col_count); + for i in 0..col_count { + let name = stmt + .column_name(i) + .map(|s| s.to_string()) + .unwrap_or_else(|_| format!("col_{}", i)); + columns.push(name); + } + + // Rows + let mut rows: Vec = Vec::new(); + while let Some(row) = stmt + .next() + .map_err(|e| DbError::QueryExecution(format!("Row fetch failed: {}", e)))? + { + let mut query_row = QueryRow::new(); + for (i, col_name) in columns.iter().enumerate() { + let val = Self::row_to_query_value(&row, i)?; + query_row.insert(col_name.clone(), val); + } + rows.push(query_row); + } + + Ok(QueryResult { + columns, + rows, + rows_affected: None, + execution_time_ms: None, + }) + } else { + let rows_affected = conn + .execute(&query_owned, &[]) + .map_err(|e| DbError::QueryExecution(format!("DML failed: {}", e)))?; + + Ok(QueryResult { + columns: Vec::new(), + rows: Vec::new(), + rows_affected: Some(rows_affected), + execution_time_ms: None, + }) + } + } + + // ── Metadata helpers: query-based (run on the primary connection) ── + + /// Run a synchronous metadata query and return the raw `oracle::ResultSet`. + /// Because metadata queries are typically short, we run them on the current + /// thread directly rather than bouncing through `spawn_blocking`. + fn run_meta_query(&self, sql: &str) -> DbResult { + let conn = self + .client + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))?; + conn.execute(sql, &[]) + .map_err(|e| DbError::QueryExecution(format!("Metadata query failed: {}", e))) + } + + /// Convert an `oracle::Row` column into a `QueryValue`. + fn row_to_query_value(row: &oracle::Row, idx: usize) -> DbResult { + // Try integer first, then float, then string fallback. + if row.is_null(idx) + .map_err(|e| DbError::TypeConversion(format!("Null check failed: {}", e)))? + { + return Ok(QueryValue::Null); + } + + // Attempt i64 + if let Ok(Some(v)) = row.get::>(idx) { + return Ok(QueryValue::Int(v)); + } + // Attempt f64 + if let Ok(Some(v)) = row.get::>(idx) { + return Ok(QueryValue::Float(v)); + } + // Fallback to string + let s: String = row + .get(idx) + .map_err(|e| DbError::TypeConversion(format!("Value conversion failed: {}", e)))?; + Ok(QueryValue::String(s)) + } + + // ── Schema / table / column metadata ── + + async fn list_databases(&self) -> DbResult> { + let db_name = self + .config + .database + .clone() + .unwrap_or_else(|| "ORACLE".to_string()); + Ok(vec![DatabaseSchema { + name: db_name, + description: Some("Oracle database".to_string()), + is_system: false, + metadata: HashMap::new(), + }]) + } + + async fn list_schemas(&self, _database: Option<&str>) -> DbResult> { + let mut result_set = self.run_meta_query( + "SELECT DISTINCT OWNER FROM ALL_TABLES ORDER BY OWNER", + )?; + + let mut schemas = Vec::new(); + while let Some(row) = result_set + .next() + .map_err(|e| DbError::QueryExecution(format!("Row fetch failed: {}", e)))? + { + let name: String = row + .get(0) + .map_err(|e| DbError::TypeConversion(format!("Schema name: {}", e)))?; + schemas.push(name); + } + Ok(schemas) + } + + async fn list_tables( + &self, + _database: Option<&str>, + schema: Option<&str>, + ) -> DbResult> { + let schema_filter = schema.unwrap_or(&self.config.username); + let sql = format!( + "SELECT TABLE_NAME, 'TABLE' FROM ALL_TABLES WHERE OWNER = '{}' ORDER BY TABLE_NAME", + Self::sanitize_name(schema_filter) + ); + + let mut result_set = self.run_meta_query(&sql)?; + + let mut tables = Vec::new(); + while let Some(row) = result_set + .next() + .map_err(|e| DbError::QueryExecution(format!("Row fetch failed: {}", e)))? + { + let name: String = row + .get(0) + .map_err(|e| DbError::TypeConversion(format!("Table name: {}", e)))?; + let table_type: String = row + .get(1) + .map_err(|e| DbError::TypeConversion(format!("Table type: {}", e)))?; + + tables.push(TableInfo { + schema: Some(schema_filter.to_string().to_uppercase()), + name, + table_type, + row_count: None, + size_bytes: None, + description: None, + metadata: HashMap::new(), + }); + } + Ok(tables) + } + + async fn list_columns( + &self, + _database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult> { + let schema_filter = schema.unwrap_or(&self.config.username); + let sql = format!( + r#" + SELECT + COLUMN_NAME, + DATA_TYPE, + NULLABLE, + DATA_DEFAULT, + CHAR_COL_DECL_LENGTH, + DATA_PRECISION, + DATA_SCALE + FROM ALL_TAB_COLUMNS + WHERE OWNER = '{}' + AND TABLE_NAME = '{}' + ORDER BY COLUMN_ID + "#, + Self::sanitize_name(schema_filter), + Self::sanitize_name(table), + ); + + let mut result_set = self.run_meta_query(&sql)?; + + let mut columns = Vec::new(); + while let Some(row) = result_set + .next() + .map_err(|e| DbError::QueryExecution(format!("Row fetch failed: {}", e)))? + { + let name: String = row + .get(0) + .map_err(|e| DbError::TypeConversion(format!("Column name: {}", e)))?; + let data_type: String = row + .get(1) + .map_err(|e| DbError::TypeConversion(format!("Data type: {}", e)))?; + let nullable_str: String = row + .get(2) + .map_err(|e| DbError::TypeConversion(format!("Nullable: {}", e)))?; + + let nullable = nullable_str == "Y"; + + let default_value: Option = if row.is_null(3).unwrap_or(true) { + None + } else { + row.get::>(3) + .ok() + .flatten() + }; + + let max_length: Option = row + .get::>(4) + .ok() + .flatten() + .filter(|&v| v > 0) + .map(|v| v as u32); + + let precision: Option = row + .get::>(5) + .ok() + .flatten() + .filter(|&v| v > 0) + .map(|v| v as u32); + + let scale: Option = row + .get::>(6) + .ok() + .flatten() + .filter(|&v| v >= 0) // 0 = integer; negative scale is unusual + .map(|v| v as u32); + + columns.push(ColumnInfo { + name, + data_type, + nullable, + default_value, + is_primary_key: false, + is_auto_increment: false, + max_length, + precision, + scale, + description: None, + metadata: HashMap::new(), + }); + } + + if columns.is_empty() { + return Err(DbError::TableNotFound(table.to_string())); + } + + Ok(columns) + } + + async fn get_table_info( + &self, + database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult { + let tables = self.list_tables(database, schema).await?; + let mut table_info = tables + .into_iter() + .find(|t| t.name == table) + .ok_or_else(|| DbError::TableNotFound(table.to_string()))?; + + // Attempt to get a row count (best-effort). + let schema_filter = schema.unwrap_or(&self.config.username); + let count_sql = format!( + "SELECT COUNT(*) AS cnt FROM \"{}\".\"{}\"", + Self::sanitize_name(schema_filter), + Self::sanitize_name(table), + ); + match self.run_meta_query(&count_sql) { + Ok(mut rs) => { + if let Some(row) = rs.next().ok().flatten() { + if let Ok(Some(cnt)) = row.get::>(0) { + table_info.row_count = Some(cnt as u64); + } + } + } + Err(_) => { /* row count is best-effort */ } + } + + Ok(table_info) + } + + // ── Pool & config ── + + fn get_pool(&self) -> Option> { + self.pool.clone() + } + + fn get_config(&self) -> &ConnectionConfig { + &self.config + } +} + +#[async_trait] +#[cfg(not(feature = "oracle"))] +impl DatabaseAdapter for OracleAdapter { + type Pool = OraclePool; + + async fn connect(&mut self) -> DbResult<()> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn disconnect(&mut self) -> DbResult<()> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn test_connection(&self) -> DbResult { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn execute_query(&self, _query: &str) -> DbResult { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn list_schemas(&self, _database: Option<&str>) -> DbResult> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn list_tables( + &self, + _database: Option<&str>, + _schema: Option<&str>, + ) -> DbResult> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn list_columns( + &self, + _database: Option<&str>, + _schema: Option<&str>, + _table: &str, + ) -> DbResult> { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + async fn get_table_info( + &self, + _database: Option<&str>, + _schema: Option<&str>, + _table: &str, + ) -> DbResult { + Err(DbError::UnsupportedOperation( + "Oracle adapter requires the 'oracle' feature".to_string(), + )) + } + + fn get_pool(&self) -> Option> { + None + } + + fn get_config(&self) -> &ConnectionConfig { + &self.config + } +} + +// ── Helper utilities ── + +impl OracleAdapter { + /// Sanitize a schema or table name for safe SQL interpolation. + /// + /// Only allows alphanumeric characters, underscores, dollar signs, and + /// hash signs (valid in Oracle identifiers). Returns a cleaned string. + fn sanitize_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '$' || *c == '#') + .collect() + } +} diff --git a/src/components/connections/ServerFormDialog.vue b/src/components/connections/ServerFormDialog.vue index 551db761..5b89b5c5 100644 --- a/src/components/connections/ServerFormDialog.vue +++ b/src/components/connections/ServerFormDialog.vue @@ -42,12 +42,38 @@ const isOpen = computed({ const isEditing = computed(() => !!props.connection?.id) -const defaultPorts: Record = { +const defaultPorts: Record = { [DatabaseType.POSTGRESQL]: 5432, [DatabaseType.MYSQL]: 3306, [DatabaseType.MARIADB]: 3306, [DatabaseType.SQLITE]: 0, [DatabaseType.SQLSERVER]: 1433, + [DatabaseType.DUCKDB]: 0, + [DatabaseType.CLICKHOUSE]: 8123, + [DatabaseType.COCKROACHDB]: 5432, + [DatabaseType.REDSHIFT]: 5439, + [DatabaseType.YUGABYTEDB]: 5433, + [DatabaseType.TIMESCALEDB]: 5432, + [DatabaseType.KINGBASEES]: 54321, + [DatabaseType.GAUSSDB]: 5432, + [DatabaseType.HIGHGO]: 5432, + [DatabaseType.UXDB]: 5432, + [DatabaseType.OPENGAUSS]: 5432, + [DatabaseType.GBASE8C]: 5432, + [DatabaseType.TIDB]: 4000, + [DatabaseType.OCEANBASE]: 2883, + [DatabaseType.TDSQL]: 3306, + [DatabaseType.POLARDB]: 3306, + [DatabaseType.DM8]: 5236, + [DatabaseType.ORACLE]: 1521, + [DatabaseType.DB2]: 50000, + [DatabaseType.H2]: 9092, + [DatabaseType.SNOWFLAKE]: 443, + [DatabaseType.DM8ORACLE]: 5236, + [DatabaseType.XUGUDB]: 5138, + [DatabaseType.GBASE8A]: 5258, + [DatabaseType.TRINO]: 8080, + [DatabaseType.PRESTO]: 8080, } const defaultConnection: ServerConnection = { diff --git a/src/composables/useDatabaseIcon.ts b/src/composables/useDatabaseIcon.ts index 6743e918..96f58f18 100644 --- a/src/composables/useDatabaseIcon.ts +++ b/src/composables/useDatabaseIcon.ts @@ -11,40 +11,55 @@ type DatabaseIconConfig = { color: string } +const PG_ICON = { icon: postgresqlLogo, color: 'bg-blue-100 dark:bg-blue-900/30' } +const MYSQL_ICON = { icon: mysqlLogo, color: 'bg-orange-100 dark:bg-orange-900/30' } +const DEFAULT_ICON = { icon: sqliteLogo, color: 'bg-gray-100 dark:bg-gray-900/30' } + const databaseIcons: Record = { - POSTGRESQL: { - icon: postgresqlLogo, - color: 'bg-blue-100 dark:bg-blue-900/30', - }, - MYSQL: { - icon: mysqlLogo, - color: 'bg-orange-100 dark:bg-orange-900/30', - }, - MARIADB: { - icon: mariadbLogo, - color: 'bg-purple-100 dark:bg-purple-900/30', - }, - SQLITE: { - icon: sqliteLogo, - color: 'bg-green-100 dark:bg-green-900/30', - }, - SQLSERVER: { - icon: mssqlLogo, - color: 'bg-red-100 dark:bg-red-900/30', - }, + POSTGRESQL: PG_ICON, + MYSQL: MYSQL_ICON, + MARIADB: { icon: mariadbLogo, color: 'bg-purple-100 dark:bg-purple-900/30' }, + SQLITE: { icon: sqliteLogo, color: 'bg-green-100 dark:bg-green-900/30' }, + SQLSERVER: { icon: mssqlLogo, color: 'bg-red-100 dark:bg-red-900/30' }, + DUCKDB: { icon: sqliteLogo, color: 'bg-yellow-100 dark:bg-yellow-900/30' }, + CLICKHOUSE: { icon: sqliteLogo, color: 'bg-red-100 dark:bg-red-900/30' }, + COCKROACHDB: PG_ICON, + REDSHIFT: PG_ICON, + YUGABYTEDB: PG_ICON, + TIMESCALEDB: PG_ICON, + KINGBASEES: PG_ICON, + GAUSSDB: PG_ICON, + HIGHGO: PG_ICON, + UXDB: PG_ICON, + OPENGAUSS: PG_ICON, + GBASE8C: PG_ICON, + TIDB: MYSQL_ICON, + OCEANBASE: MYSQL_ICON, + TDSQL: MYSQL_ICON, + POLARDB: MYSQL_ICON, + DM8: MYSQL_ICON, + ORACLE: DEFAULT_ICON, + DB2: DEFAULT_ICON, + H2: DEFAULT_ICON, + SNOWFLAKE: DEFAULT_ICON, + DM8ORACLE: DEFAULT_ICON, + XUGUDB: DEFAULT_ICON, + GBASE8A: DEFAULT_ICON, + TRINO: DEFAULT_ICON, + PRESTO: DEFAULT_ICON, } export function useDatabaseIcon() { const getDatabaseIcon = (type: DatabaseType): string => { - return databaseIcons[type]?.icon ?? postgresqlLogo + return databaseIcons[type]?.icon ?? DEFAULT_ICON.icon } const getDatabaseColor = (type: DatabaseType): string => { - return databaseIcons[type]?.color ?? 'bg-gray-100 dark:bg-gray-900/30' + return databaseIcons[type]?.color ?? DEFAULT_ICON.color } const getDatabaseConfig = (type: DatabaseType): DatabaseIconConfig => { - return databaseIcons[type] ?? databaseIcons.POSTGRESQL + return databaseIcons[type] ?? DEFAULT_ICON } return { diff --git a/src/store/connectionStore.ts b/src/store/connectionStore.ts index 0d633355..ec01b6dc 100644 --- a/src/store/connectionStore.ts +++ b/src/store/connectionStore.ts @@ -9,14 +9,69 @@ export enum DatabaseType { MARIADB = 'MARIADB', SQLITE = 'SQLITE', SQLSERVER = 'SQLSERVER', + DUCKDB = 'DUCKDB', + CLICKHOUSE = 'CLICKHOUSE', + COCKROACHDB = 'COCKROACHDB', + REDSHIFT = 'REDSHIFT', + YUGABYTEDB = 'YUGABYTEDB', + TIMESCALEDB = 'TIMESCALEDB', + KINGBASEES = 'KINGBASEES', + GAUSSDB = 'GAUSSDB', + HIGHGO = 'HIGHGO', + UXDB = 'UXDB', + OPENGAUSS = 'OPENGAUSS', + GBASE8C = 'GBASE8C', + TIDB = 'TIDB', + OCEANBASE = 'OCEANBASE', + TDSQL = 'TDSQL', + POLARDB = 'POLARDB', + DM8 = 'DM8', + ORACLE = 'ORACLE', + DB2 = 'DB2', + H2 = 'H2', + SNOWFLAKE = 'SNOWFLAKE', + DM8ORACLE = 'DM8ORACLE', + XUGUDB = 'XUGUDB', + GBASE8A = 'GBASE8A', + TRINO = 'TRINO', + PRESTO = 'PRESTO', } +const PG_BACKEND = 'PostgreSQL' +const MYSQL_BACKEND = 'MySQL' + const dbTypeToBackend: Record = { - [DatabaseType.POSTGRESQL]: 'PostgreSQL', - [DatabaseType.MYSQL]: 'MySQL', - [DatabaseType.MARIADB]: 'MySQL', + [DatabaseType.POSTGRESQL]: PG_BACKEND, + [DatabaseType.MYSQL]: MYSQL_BACKEND, + [DatabaseType.MARIADB]: MYSQL_BACKEND, [DatabaseType.SQLITE]: 'SQLite', [DatabaseType.SQLSERVER]: 'SqlServer', + [DatabaseType.DUCKDB]: 'duckdb', + [DatabaseType.CLICKHOUSE]: 'clickhouse', + [DatabaseType.COCKROACHDB]: PG_BACKEND, + [DatabaseType.REDSHIFT]: PG_BACKEND, + [DatabaseType.YUGABYTEDB]: PG_BACKEND, + [DatabaseType.TIMESCALEDB]: PG_BACKEND, + [DatabaseType.KINGBASEES]: PG_BACKEND, + [DatabaseType.GAUSSDB]: PG_BACKEND, + [DatabaseType.HIGHGO]: PG_BACKEND, + [DatabaseType.UXDB]: PG_BACKEND, + [DatabaseType.OPENGAUSS]: PG_BACKEND, + [DatabaseType.GBASE8C]: PG_BACKEND, + [DatabaseType.TIDB]: MYSQL_BACKEND, + [DatabaseType.OCEANBASE]: MYSQL_BACKEND, + [DatabaseType.TDSQL]: MYSQL_BACKEND, + [DatabaseType.POLARDB]: MYSQL_BACKEND, + [DatabaseType.DM8]: MYSQL_BACKEND, + [DatabaseType.ORACLE]: 'oracle', + [DatabaseType.DB2]: 'db2', + [DatabaseType.H2]: 'h2', + [DatabaseType.SNOWFLAKE]: 'snowflake', + [DatabaseType.DM8ORACLE]: 'dm8_oracle', + [DatabaseType.XUGUDB]: 'xugudb', + [DatabaseType.GBASE8A]: 'gbase8a', + [DatabaseType.TRINO]: 'trino', + [DatabaseType.PRESTO]: 'presto', } const dbTypeFromBackend: Record = { @@ -24,11 +79,25 @@ const dbTypeFromBackend: Record = { MySQL: DatabaseType.MYSQL, SqlServer: DatabaseType.SQLSERVER, SQLite: DatabaseType.SQLITE, + duckdb: DatabaseType.DUCKDB, + clickhouse: DatabaseType.CLICKHOUSE, + oracle: DatabaseType.ORACLE, + db2: DatabaseType.DB2, + h2: DatabaseType.H2, + snowflake: DatabaseType.SNOWFLAKE, + dm8_oracle: DatabaseType.DM8ORACLE, + xugudb: DatabaseType.XUGUDB, + gbase8a: DatabaseType.GBASE8A, + trino: DatabaseType.TRINO, + presto: DatabaseType.PRESTO, } const defaultDatabaseFor: Partial> = { [DatabaseType.POSTGRESQL]: 'postgres', [DatabaseType.SQLSERVER]: 'master', + [DatabaseType.DUCKDB]: ':memory:', + [DatabaseType.CLICKHOUSE]: 'default', + [DatabaseType.HIGHGO]: 'highgo', } function resolveDatabase(type: DatabaseType, database?: string): string | null { @@ -78,207 +147,3 @@ type ConnectionStoreState = { export const useConnectionStore = defineStore('connectionStore', { state: (): ConnectionStoreState => ({ - connections: [], - activeConnectionId: null, - connectionStatus: {}, - currentDatabases: {}, - }), - getters: { - activeConnection: (state): ServerConnection | undefined => - state.connections.find(c => c.id === state.activeConnectionId), - - connectedConnections: (state): ServerConnection[] => - state.connections.filter(c => c.isConnected), - - connectionOptions(state) { - return state.connections.map(({ name }) => ({ label: name, value: name })) - }, - getConnectionById: state => (id: string) => { - return state.connections.find(c => c.id === id) - }, - getConnectionByName: state => (name: string) => { - return state.connections.find(c => c.name === name) - }, - getConnectionStatus: state => (id: string): ConnectionStatus => - state.connectionStatus[id] ?? ConnectionStatus.DISCONNECTED, - /** Returns the currently active database for a connection: backend-resolved default or configured value. */ - getCurrentDatabase: state => (id: string): string => - state.currentDatabases[id] ?? state.connections.find(c => c.id === id)?.database ?? '', - }, - actions: { - async fetchConnections() { - try { - const backendConnections = await connectionApi.list() - - this.connections = backendConnections.map(conn => ({ - id: conn.id, - name: conn.name, - type: dbTypeFromBackend[conn.db_type] || DatabaseType.POSTGRESQL, - host: conn.host, - port: conn.port, - username: conn.username, - password: conn.password || undefined, - database: conn.database || undefined, - ssl: sslModeFromBackend(conn.ssl_mode), - isConnected: false, - })) - } - catch (error) { - console.error('Failed to fetch connections:', error) - this.connections = [] - } - }, - - async saveConnection(connection: ServerConnection): Promise<{ success: boolean, message: string }> { - try { - const id = connection.id || crypto.randomUUID() - - const serverConfig = { - id, - name: connection.name, - db_type: dbTypeToBackend[connection.type] || 'PostgreSQL', - host: connection.host, - port: connection.port, - username: connection.username || '', - password: connection.password || null, - database: connection.database || null, - ssl_mode: sslModeToBackend(connection.ssl), - ssl_ca_cert: connection.ssl.caCertPath || null, - ssl_client_cert: connection.ssl.clientCertPath || null, - ssl_client_key: connection.ssl.clientKeyPath || null, - trust_server_certificate: connection.ssl.trustServerCertificate ?? null, - } - - await connectionApi.save(serverConfig) - - // Update local state - const newConnection = { ...connection, id } - if (connection.id) { - const index = this.connections.findIndex(c => c.id === connection.id) - if (index !== -1) { - this.connections = [ - ...this.connections.slice(0, index), - newConnection, - ...this.connections.slice(index + 1), - ] - } - } - else { - this.connections = [...this.connections, newConnection] - } - - return { success: true, message: 'Connection saved successfully' } - } - catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error', - } - } - }, - - async removeConnection(connection: ServerConnection) { - if (connection.id) { - await connectionApi.delete(connection.id) - this.connections = this.connections.filter(c => c.id !== connection.id) - } - }, - - async testConnection(connection: ServerConnection): Promise { - try { - const serverConfig = { - id: connection.id || crypto.randomUUID(), - name: connection.name, - db_type: dbTypeToBackend[connection.type] || 'PostgreSQL', - host: connection.host, - port: connection.port, - username: connection.username || '', - password: connection.password || null, - database: resolveDatabase(connection.type, connection.database), - ssl_mode: sslModeToBackend(connection.ssl), - ssl_ca_cert: connection.ssl.caCertPath || null, - ssl_client_cert: connection.ssl.clientCertPath || null, - ssl_client_key: connection.ssl.clientKeyPath || null, - trust_server_certificate: connection.ssl.trustServerCertificate ?? null, - } - - const result = await connectionApi.test(serverConfig) - return result.is_connected - } - catch (error) { - console.error('Connection test failed:', error) - return false - } - }, - - async connect(connectionId: string) { - const connection = this.getConnectionById(connectionId) - if (!connection) { - throw new Error(`Connection not found: ${connectionId}`) - } - - this.connectionStatus[connectionId] = ConnectionStatus.CONNECTING - - try { - const serverConfig = { - id: connection.id!, - name: connection.name, - db_type: dbTypeToBackend[connection.type] || 'PostgreSQL', - host: connection.host, - port: connection.port, - username: connection.username || '', - password: connection.password || null, - database: resolveDatabase(connection.type, connection.database), - ssl_mode: sslModeToBackend(connection.ssl), - ssl_ca_cert: connection.ssl.caCertPath || null, - ssl_client_cert: connection.ssl.clientCertPath || null, - ssl_client_key: connection.ssl.clientKeyPath || null, - trust_server_certificate: connection.ssl.trustServerCertificate ?? null, - } - - const result = await connectionApi.connect(serverConfig) - - connection.isConnected = true - connection.lastUsed = new Date() - this.connectionStatus[connectionId] = ConnectionStatus.CONNECTED - this.activeConnectionId = connectionId - // Persist the actual connected database (may be the resolved default). - const resolvedDb = result.current_database || resolveDatabase(connection.type, connection.database) - if (resolvedDb) { - this.currentDatabases[connectionId] = resolvedDb - } - return result - } - catch (error) { - this.connectionStatus[connectionId] = ConnectionStatus.ERROR - throw new Error(`Failed to connect: ${error}`) - } - }, - - async disconnect(connectionId: string) { - try { - await connectionApi.disconnect(connectionId) - } - finally { - const connection = this.getConnectionById(connectionId) - if (connection) { - connection.isConnected = false - } - this.connectionStatus[connectionId] = ConnectionStatus.DISCONNECTED - delete this.currentDatabases[connectionId] - if (this.activeConnectionId === connectionId) { - this.activeConnectionId = null - } - } - }, - - setActiveConnection(connectionId: string | null) { - this.activeConnectionId = connectionId - }, - - /** Persist the user-selected database for a connection so it survives navigation. */ - setCurrentDatabase(connectionId: string, database: string) { - this.currentDatabases[connectionId] = database - }, - }, -}) diff --git a/src/store/dataStudioStore.ts b/src/store/dataStudioStore.ts index 955a6966..29f85a70 100644 --- a/src/store/dataStudioStore.ts +++ b/src/store/dataStudioStore.ts @@ -21,7 +21,7 @@ export type DatabaseSource = { sourceId: string connectionId: number name: string - databaseType: 'POSTGRESQL' | 'MYSQL' | 'SQLSERVER' | 'SQLITE' + databaseType: 'POSTGRESQL' | 'MYSQL' | 'SQLSERVER' | 'SQLITE' | 'DUCKDB' | 'CLICKHOUSE' | 'ORACLE' | 'DB2' | 'H2' | 'SNOWFLAKE' | 'TRINO' | 'PRESTO' | 'COCKROACHDB' | 'REDSHIFT' | 'MARIADB' | 'TIDB' | 'OCEANBASE' | 'TDSQL' permissions: DataSourcePermissions } From e50aaface5f0e9b47bcf91d4f3ef363e4c323f0f Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 13 Jun 2026 16:34:32 +0800 Subject: [PATCH 3/3] refactor: replace ODBC with JDBC bridge, complete frontend type integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove odbc crate + OdbcAdapter (828 lines) - Add jdbc_bridge Rust module (JSON-RPC over stdin/stdout to Java subprocess) - Add jdbc-bridge/ Java project (HikariCP + Jackson, fat JAR) - Auto-download JRE + bridge JAR + driver JARs on first connect - No system ODBC drivers nor system Java required - Fix Oracle routing to native oracle-rs adapter - Export dbTypeToBackend from store with full test coverage (6 tests) - Restore truncated defineStore in connectionStore.ts - Expand ServerFormDialog dropdown from 5 to 31 database types - Add DuckDB file-based mode alongside SQLite - Add 26 bilingual i18n labels (EN + ZH, 信创 bilingual) - Fix SSL config section to cover all DatabaseType variants - Update all docstrings: ODBC bridge → JDBC bridge Verification: cargo check 0 errors, cargo test 121/121, jest 282/282, vue-tsc clean, eslint 0 errors --- jdbc-bridge/pom.xml | 81 ++ .../main/java/sqlkit/bridge/BridgeMain.java | 50 ++ .../java/sqlkit/bridge/ConnectionManager.java | 117 +++ .../java/sqlkit/bridge/MetadataProvider.java | 104 +++ .../java/sqlkit/bridge/ProtocolHandler.java | 184 ++++ .../java/sqlkit/bridge/QueryExecutor.java | 125 +++ jdbc-bridge/src/main/resources/logback.xml | 17 + src-tauri/Cargo.lock | 37 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/commands/browse.rs | 32 +- src-tauri/src/commands/connection.rs | 4 +- src-tauri/src/commands/helpers.rs | 32 +- src-tauri/src/commands/query.rs | 16 +- src-tauri/src/database/config.rs | 16 +- src-tauri/src/database/jdbc_bridge/adapter.rs | 357 ++++++++ .../src/database/jdbc_bridge/download.rs | 290 ++++++ .../src/database/jdbc_bridge/launcher.rs | 170 ++++ src-tauri/src/database/jdbc_bridge/mod.rs | 27 + src-tauri/src/database/jdbc_bridge/pool.rs | 58 ++ .../src/database/jdbc_bridge/protocol.rs | 123 +++ src-tauri/src/database/mod.rs | 6 +- src-tauri/src/database/odbc.rs | 828 ------------------ src-tauri/src/database/strategy.rs | 36 +- src-tauri/src/state.rs | 10 +- .../connections/ServerFormDialog.vue | 220 ++++- .../connections/ssl/SslConfigSection.vue | 28 +- src/lang/enUS.ts | 26 + src/lang/zhCN.ts | 26 + src/store/connectionStore.ts | 218 ++++- src/store/index.ts | 1 + tests/store/connectionStore.test.ts | 60 +- 31 files changed, 2347 insertions(+), 957 deletions(-) create mode 100644 jdbc-bridge/pom.xml create mode 100644 jdbc-bridge/src/main/java/sqlkit/bridge/BridgeMain.java create mode 100644 jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java create mode 100644 jdbc-bridge/src/main/java/sqlkit/bridge/MetadataProvider.java create mode 100644 jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java create mode 100644 jdbc-bridge/src/main/java/sqlkit/bridge/QueryExecutor.java create mode 100644 jdbc-bridge/src/main/resources/logback.xml create mode 100644 src-tauri/src/database/jdbc_bridge/adapter.rs create mode 100644 src-tauri/src/database/jdbc_bridge/download.rs create mode 100644 src-tauri/src/database/jdbc_bridge/launcher.rs create mode 100644 src-tauri/src/database/jdbc_bridge/mod.rs create mode 100644 src-tauri/src/database/jdbc_bridge/pool.rs create mode 100644 src-tauri/src/database/jdbc_bridge/protocol.rs delete mode 100644 src-tauri/src/database/odbc.rs diff --git a/jdbc-bridge/pom.xml b/jdbc-bridge/pom.xml new file mode 100644 index 00000000..604ceff5 --- /dev/null +++ b/jdbc-bridge/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + com.sqlkit + jdbc-bridge + 1.0.0 + jar + + SQLKit JDBC Bridge + Lightweight JDBC proxy for SQLKit — connects to databases via JDBC and exposes JSON-RPC over stdin/stdout + + + 17 + 17 + UTF-8 + 5.1.0 + 2.17.2 + 1.5.7 + + + + + + com.zaxxer + HikariCP + ${hikaricp.version} + + + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + + ch.qos.logback + logback-classic + ${logback.version} + + + org.slf4j + slf4j-api + 2.0.13 + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + false + + + sqlkit.bridge.BridgeMain + + + + + + package + shade + + + + + + diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/BridgeMain.java b/jdbc-bridge/src/main/java/sqlkit/bridge/BridgeMain.java new file mode 100644 index 00000000..9c1a2fd2 --- /dev/null +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/BridgeMain.java @@ -0,0 +1,50 @@ +package sqlkit.bridge; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.*; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * BridgeMain — entry point for the SQLKit JDBC bridge. + * + * Reads JSON-RPC requests from stdin, dispatches them, and writes responses to stdout. + * Each line on stdin is a complete JSON request. Each response is a single JSON line on stdout. + */ +public class BridgeMain { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Map CONNECTIONS = new ConcurrentHashMap<>(); + private static final ProtocolHandler HANDLER = new ProtocolHandler(CONNECTIONS); + + public static void main(String[] args) throws Exception { + // Disable Jackson's FAIL_ON_EMPTY_BEANS for safety + MAPPER.disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + PrintWriter writer = new PrintWriter(new OutputStreamWriter(System.out)); + + String line; + while ((line = reader.readLine()) != null) { + if (line.trim().isEmpty()) { + continue; + } + try { + JsonNode request = MAPPER.readTree(line); + JsonNode response = HANDLER.handle(request); + writer.println(MAPPER.writeValueAsString(response)); + writer.flush(); + } catch (Exception e) { + // Send error response + ObjectNode errorResp = MAPPER.createObjectNode(); + errorResp.put("id", -1); + errorResp.put("error", "Internal error: " + e.getMessage()); + writer.println(MAPPER.writeValueAsString(errorResp)); + writer.flush(); + } + } + } +} diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java new file mode 100644 index 00000000..3a80d3e4 --- /dev/null +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java @@ -0,0 +1,117 @@ +package sqlkit.bridge; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.sql.*; +import java.util.*; + +/** + * Manages HikariCP connection pools for JDBC bridge connections. + * Each connection is identified by a unique conn_id string. + */ +public class ConnectionManager { + + private final Map pools = new HashMap<>(); + + /** + * Create a new JDBC connection pool. + * + * @param connId unique identifier for this connection + * @param url JDBC URL + * @param username database username + * @param password database password + * @param driverClass JDBC driver class name + * @param minPool minimum pool size + * @param maxPool maximum pool size + */ + public void connect(String connId, String url, String username, + String password, String driverClass, + int minPool, int maxPool) throws Exception { + if (pools.containsKey(connId)) { + throw new Exception("Connection already exists: " + connId); + } + + // Load the JDBC driver class + Class.forName(driverClass); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(url); + config.setUsername(username); + if (password != null && !password.isEmpty()) { + config.setPassword(password); + } + config.setMinimumIdle(minPool); + config.setMaximumPoolSize(maxPool); + config.setConnectionTimeout(30000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + HikariDataSource ds = new HikariDataSource(config); + + // Verify connection works + try (Connection c = ds.getConnection()) { + // ok + } catch (Exception e) { + ds.close(); + throw new Exception("Failed to verify connection: " + e.getMessage()); + } + + pools.put(connId, ds); + } + + /** + * Close and remove a connection pool. + */ + public void disconnect(String connId) { + HikariDataSource ds = pools.remove(connId); + if (ds != null) { + ds.close(); + } + } + + /** + * Get a connection from the pool for the given connId. + */ + public Connection getConnection(String connId) throws Exception { + HikariDataSource ds = pools.get(connId); + if (ds == null) { + throw new Exception("Connection not found: " + connId); + } + return ds.getConnection(); + } + + /** + * Test a connection — return status metadata as a Map. + */ + public Map testConnection(String connId) throws Exception { + try (Connection c = getConnection(connId)) { + Map status = new LinkedHashMap<>(); + status.put("is_connected", true); + + DatabaseMetaData meta = c.getMetaData(); + status.put("server_version", meta.getDatabaseProductVersion()); + status.put("current_database", c.getCatalog()); + try { + status.put("current_user", meta.getUserName()); + } catch (Exception e) { + status.put("current_user", null); + } + + return status; + } + } + + /** + * Close all connection pools. + */ + public void closeAll() { + for (HikariDataSource ds : pools.values()) { + ds.close(); + } + pools.clear(); + } +} diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/MetadataProvider.java b/jdbc-bridge/src/main/java/sqlkit/bridge/MetadataProvider.java new file mode 100644 index 00000000..b7d604c6 --- /dev/null +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/MetadataProvider.java @@ -0,0 +1,104 @@ +package sqlkit.bridge; + +import java.sql.*; +import java.util.*; + +/** + * Provides database metadata: databases, schemas, tables, columns. + */ +public class MetadataProvider { + + /** + * List all databases (catalogs) on the server. + */ + public static List listDatabases(Connection conn) throws Exception { + List databases = new ArrayList<>(); + try (ResultSet rs = conn.getMetaData().getCatalogs()) { + while (rs.next()) { + databases.add(rs.getString("TABLE_CAT")); + } + } + return databases; + } + + /** + * List all schemas in the given database (catalog). + */ + public static List listSchemas(Connection conn, String database) throws Exception { + List schemas = new ArrayList<>(); + String catalog = (database != null && !database.isEmpty()) ? database : null; + try (ResultSet rs = conn.getMetaData().getSchemas(catalog, null)) { + while (rs.next()) { + schemas.add(rs.getString("TABLE_SCHEM")); + } + } + return schemas; + } + + /** + * List all tables in the given catalog/schema. + */ + public static List> listTables(Connection conn, + String database, + String schema) throws Exception { + List> tables = new ArrayList<>(); + String catalog = (database != null && !database.isEmpty()) ? database : null; + String schemaPattern = (schema != null && !schema.isEmpty()) ? schema : null; + + try (ResultSet rs = conn.getMetaData().getTables(catalog, schemaPattern, null, + new String[]{"TABLE", "VIEW", "SYSTEM TABLE", "ALIAS", "SYNONYM"})) { + while (rs.next()) { + Map t = new LinkedHashMap<>(); + t.put("name", rs.getString("TABLE_NAME")); + t.put("schema", rs.getString("TABLE_SCHEM")); + t.put("table_type", rs.getString("TABLE_TYPE")); + t.put("row_count", null); + tables.add(t); + } + } + return tables; + } + + /** + * List all columns for a given table. + */ + public static List> listColumns(Connection conn, + String database, + String schema, + String table) throws Exception { + List> columns = new ArrayList<>(); + String catalog = (database != null && !database.isEmpty()) ? database : null; + String schemaPattern = (schema != null && !schema.isEmpty()) ? schema : null; + + try (ResultSet rs = conn.getMetaData().getColumns(catalog, schemaPattern, table, null)) { + while (rs.next()) { + Map c = new LinkedHashMap<>(); + c.put("name", rs.getString("COLUMN_NAME")); + c.put("data_type", rs.getString("TYPE_NAME")); + c.put("nullable", rs.getInt("NULLABLE") == DatabaseMetaData.columnNullable); + c.put("default_value", rs.getString("COLUMN_DEF")); + c.put("is_primary_key", false); // filled below + c.put("is_auto_increment", "YES".equalsIgnoreCase(rs.getString("IS_AUTOINCREMENT"))); + c.put("max_length", rs.getInt("COLUMN_SIZE")); + c.put("precision", rs.getInt("DECIMAL_DIGITS")); + c.put("scale", rs.getInt("DECIMAL_DIGITS")); + columns.add(c); + } + } + + // Fetch primary keys for this table to set is_primary_key + Set pkColumns = new HashSet<>(); + try (ResultSet rs = conn.getMetaData().getPrimaryKeys(catalog, schemaPattern, table)) { + while (rs.next()) { + pkColumns.add(rs.getString("COLUMN_NAME")); + } + } + for (Map c : columns) { + if (pkColumns.contains(c.get("name"))) { + c.put("is_primary_key", true); + } + } + + return columns; + } +} diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java b/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java new file mode 100644 index 00000000..da3637ed --- /dev/null +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java @@ -0,0 +1,184 @@ +package sqlkit.bridge; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.sql.Connection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Dispatches JSON-RPC requests to the appropriate handler. + */ +public class ProtocolHandler { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final ConnectionManager connectionManager; + + public ProtocolHandler(ConnectionManager connectionManager) { + this.connectionManager = connectionManager; + } + + /** + * Handle a single JSON-RPC request and return a response. + */ + public ObjectNode handle(JsonNode request) { + long id = request.has("id") ? request.get("id").asLong(-1) : -1; + String method = request.has("method") ? request.get("method").asText("") : ""; + JsonNode params = request.has("params") ? request.get("params") : MAPPER.missingNode(); + + try { + ObjectNode response = MAPPER.createObjectNode(); + response.put("id", id); + + switch (method) { + case "ping": + response.put("result", "pong"); + break; + + case "connect": + handleConnect(params, response); + break; + + case "disconnect": + handleDisconnect(params, response); + break; + + case "execute_query": + handleExecuteQuery(params, response); + break; + + case "list_databases": + handleListDatabases(params, response); + break; + + case "list_schemas": + handleListSchemas(params, response); + break; + + case "list_tables": + handleListTables(params, response); + break; + + case "list_columns": + handleListColumns(params, response); + break; + + case "test_connection": + handleTestConnection(params, response); + break; + + default: + response.put("error", "Unknown method: " + method); + break; + } + + return response; + } catch (Exception e) { + ObjectNode errorResp = MAPPER.createObjectNode(); + errorResp.put("id", id); + errorResp.put("error", e.getMessage()); + return errorResp; + } + } + + private void handleConnect(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", + UUID.randomUUID().toString()); + String url = requiredString(params, "url", null); + String username = params.has("username") ? params.get("username").asText("") : ""; + String password = params.has("password") && !params.get("password").isNull() + ? params.get("password").asText() : null; + 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; + + connectionManager.connect(connId, url, username, password, driverClass, poolMin, poolMax); + response.put("result", connId); + } + + private void handleDisconnect(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", null); + connectionManager.disconnect(connId); + response.put("result", "ok"); + } + + private void handleExecuteQuery(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", null); + String sql = requiredString(params, "sql", null); + + try (Connection conn = connectionManager.getConnection(connId)) { + Map result = QueryExecutor.execute(conn, sql); + JsonNode json = MAPPER.valueToTree(result); + response.set("result", json); + } + } + + private void handleListDatabases(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", null); + try (Connection conn = connectionManager.getConnection(connId)) { + List databases = MetadataProvider.listDatabases(conn); + ArrayNode arr = MAPPER.valueToTree(databases); + response.set("result", arr); + } + } + + private void handleListSchemas(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", null); + String database = params.has("database") && !params.get("database").isNull() + ? params.get("database").asText() : null; + try (Connection conn = connectionManager.getConnection(connId)) { + List schemas = MetadataProvider.listSchemas(conn, database); + ArrayNode arr = MAPPER.valueToTree(schemas); + response.set("result", arr); + } + } + + private void handleListTables(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", null); + String database = params.has("database") && !params.get("database").isNull() + ? params.get("database").asText() : null; + String schema = params.has("schema") && !params.get("schema").isNull() + ? params.get("schema").asText() : null; + try (Connection conn = connectionManager.getConnection(connId)) { + List> tables = MetadataProvider.listTables(conn, database, schema); + ArrayNode arr = MAPPER.valueToTree(tables); + response.set("result", arr); + } + } + + private void handleListColumns(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", null); + String database = params.has("database") && !params.get("database").isNull() + ? params.get("database").asText() : null; + String schema = params.has("schema") && !params.get("schema").isNull() + ? params.get("schema").asText() : null; + String table = requiredString(params, "table", null); + try (Connection conn = connectionManager.getConnection(connId)) { + List> columns = MetadataProvider.listColumns(conn, database, schema, table); + ArrayNode arr = MAPPER.valueToTree(columns); + response.set("result", arr); + } + } + + private void handleTestConnection(JsonNode params, ObjectNode response) throws Exception { + String connId = requiredString(params, "conn_id", null); + Map status = connectionManager.testConnection(connId); + JsonNode json = MAPPER.valueToTree(status); + response.set("result", json); + } + + private String requiredString(JsonNode params, String key, String defaultValue) throws Exception { + if (params.has(key) && !params.get(key).isNull()) { + return params.get(key).asText(); + } + if (defaultValue != null) { + return defaultValue; + } + throw new Exception("Missing required parameter: " + key); + } +} diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/QueryExecutor.java b/jdbc-bridge/src/main/java/sqlkit/bridge/QueryExecutor.java new file mode 100644 index 00000000..71549956 --- /dev/null +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/QueryExecutor.java @@ -0,0 +1,125 @@ +package sqlkit.bridge; + +import java.sql.*; +import java.util.*; + +/** + * Executes SQL queries against a JDBC connection and serializes results to JSON-compatible Maps. + */ +public class QueryExecutor { + + /** + * Execute a SQL query and return the result as a Map. + *

+ * For SELECT queries, returns {columns: [...], rows: [[...], ...]}. + * For UPDATE/INSERT/DELETE, returns {rows_affected: N}. + */ + public static Map execute(Connection conn, String sql) throws Exception { + sql = sql.trim(); + + boolean isQuery; + String upper = sql.toUpperCase().trim(); + isQuery = upper.startsWith("SELECT") + || upper.startsWith("WITH") + || upper.startsWith("EXPLAIN") + || upper.startsWith("SHOW") + || upper.startsWith("DESCRIBE") + || upper.startsWith("PRAGMA"); + + if (isQuery) { + return executeQuery(conn, sql); + } else { + return executeUpdate(conn, sql); + } + } + + private static Map executeQuery(Connection conn, String sql) throws Exception { + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + + ResultSetMetaData meta = rs.getMetaData(); + int columnCount = meta.getColumnCount(); + + List columns = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + columns.add(meta.getColumnLabel(i)); + } + + List> rows = new ArrayList<>(); + while (rs.next()) { + List row = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + row.add(getValue(rs, i)); + } + rows.add(row); + } + + Map result = new LinkedHashMap<>(); + result.put("columns", columns); + result.put("rows", rows); + return result; + } + } + + private static Map executeUpdate(Connection conn, String sql) throws Exception { + try (Statement stmt = conn.createStatement()) { + int affected = stmt.executeUpdate(sql); + Map result = new LinkedHashMap<>(); + result.put("rows_affected", (long) affected); + result.put("columns", Collections.emptyList()); + result.put("rows", Collections.emptyList()); + return result; + } + } + + /** + * Extract a value from a ResultSet at the given column index, converting to + * a JSON-friendly Java type. + */ + private static Object getValue(ResultSet rs, int index) throws SQLException { + Object val = rs.getObject(index); + if (val == null) { + return null; + } + // Convert specific JDBC types to plain Java types + if (val instanceof Blob) { + Blob blob = (Blob) val; + byte[] bytes = blob.getBytes(1, (int) blob.length()); + return Base64.getEncoder().encodeToString(bytes); + } + if (val instanceof Clob) { + Clob clob = (Clob) val; + return clob.getSubString(1, (int) clob.length()); + } + if (val instanceof java.sql.Date) { + return val.toString(); + } + if (val instanceof java.sql.Time) { + return val.toString(); + } + if (val instanceof java.sql.Timestamp) { + return val.toString(); + } + if (val instanceof java.util.Date) { + return val.toString(); + } + if (val instanceof byte[]) { + return Base64.getEncoder().encodeToString((byte[]) val); + } + if (val instanceof java.math.BigDecimal) { + return ((java.math.BigDecimal) val).toPlainString(); + } + // For arrays, convert to list of strings + if (val instanceof java.sql.Array) { + java.sql.Array arr = (java.sql.Array) val; + Object[] arrElements = (Object[]) arr.getArray(); + List elements = new ArrayList<>(); + for (Object elem : arrElements) { + elements.add(elem == null ? null : elem.toString()); + } + return String.join(",", elements); + } + // Return as-is for simple types (String, Integer, Long, Double, Boolean) + return val; + } +} diff --git a/jdbc-bridge/src/main/resources/logback.xml b/jdbc-bridge/src/main/resources/logback.xml new file mode 100644 index 00000000..ea237c3f --- /dev/null +++ b/jdbc-bridge/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dab7be13..83dd64ba 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1645,12 +1645,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "doc-comment" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" - [[package]] name = "dom_query" version = "0.27.0" @@ -4008,34 +4002,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "odbc" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2056ebb920918e743ccd0ceb6bc9d8e02e4d0d48c28550d1887e7490f6f298" -dependencies = [ - "doc-comment", - "encoding_rs", - "log", - "odbc-safe", - "odbc-sys", -] - -[[package]] -name = "odbc-safe" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f45de02ae2d07b38a7ef0e64139d971b1590d834c2ec089132d0eb49678e7e5a" -dependencies = [ - "odbc-sys", -] - -[[package]] -name = "odbc-sys" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec5c5490e13c423d25508b13cd2cc432490043f5ed5b46b61a75857642f4f37" - [[package]] name = "once_cell" version = "1.21.4" @@ -6093,13 +6059,13 @@ dependencies = [ "chrono", "deadpool-postgres", "duckdb", + "flate2", "futures", "hex", "http", "log", "mysql_async", "native-tls", - "odbc", "oracle-rs", "postgres-native-tls", "rand 0.8.5", @@ -6109,6 +6075,7 @@ dependencies = [ "rust_xlsxwriter", "serde", "serde_json", + "tar", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e822901d..daf582ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -77,9 +77,12 @@ log = "0.4" futures = "0.3" rand = "0.8" +# Archive extraction (JRE downloads) +flate2 = "1.0" +tar = "0.4" + # Database expansion adapters duckdb = { version = "1.3", optional = true, features = ["bundled"] } -odbc = "0.17" # Pure Rust Oracle TNS implementation, optional feature oracle-rs = { version = "0.1", optional = true } diff --git a/src-tauri/src/commands/browse.rs b/src-tauri/src/commands/browse.rs index 05a12cdf..588d5bda 100644 --- a/src-tauri/src/commands/browse.rs +++ b/src-tauri/src/commands/browse.rs @@ -5,7 +5,7 @@ use crate::database::{ ClickHouseAdapter, ColumnInfo, DatabaseAdapter, DatabaseSchema, DuckDbAdapter, HttpSqlAdapter, - MySQLAdapter, OdbcAdapter, PostgresAdapter, QueryResult, SqlServerAdapter, TableInfo, + JdbcBridgeAdapter, MySQLAdapter, PostgresAdapter, QueryResult, SqlServerAdapter, TableInfo, }; use crate::state::{ActiveConnection, AppState}; use serde::{Deserialize, Serialize}; @@ -42,7 +42,7 @@ fn quote_identifier(identifier: &str, db_type: &str) -> String { "sqlite" => format!("\"{}\"", identifier.replace("\"", "\"\"")), "duckdb" => format!("\"{}\"", identifier.replace("\"", "\"\"")), "clickhouse" => format!("`{}`", identifier.replace("`", "``")), - "odbc" => format!("\"{}\"", identifier.replace("\"", "\"\"")), + "jdbc" => format!("\"{}\"", identifier.replace("\"", "\"\"")), "trino" => identifier.to_string(), _ => identifier.to_string(), } @@ -147,7 +147,7 @@ pub async fn list_databases( let adapter = adapter.lock().await; adapter.list_databases().await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; adapter.list_databases().await } @@ -213,7 +213,7 @@ pub async fn list_schemas( let adapter = adapter.lock().await; adapter.list_schemas(Some(&database)).await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; adapter.list_schemas(Some(&database)).await } @@ -286,7 +286,7 @@ pub async fn list_tables( .list_tables(Some(&database), schema.as_deref()) .await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; adapter .list_tables(Some(&database), schema.as_deref()) @@ -367,7 +367,7 @@ pub async fn get_table_info( .get_table_info(Some(&database), None, &table_name) .await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; adapter .get_table_info(Some(&database), schema.as_deref(), &table_name) @@ -458,7 +458,7 @@ pub async fn list_columns( .list_columns(Some(&database), None, &table_name) .await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; adapter .list_columns(None, schema.as_deref(), &table_name) @@ -582,10 +582,10 @@ pub async fn get_table_data( build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "clickhouse"); adapter.execute_query(&sql).await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; - let qualified = build_qualified_table(query.schema.as_deref(), &query.table, "odbc"); - let sql = build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "odbc"); + let qualified = build_qualified_table(query.schema.as_deref(), &query.table, "jdbc"); + let sql = build_paginated_select(&qualified, filter_ref, limit_val, offset_val, "jdbc"); adapter.execute_query(&sql).await } ActiveConnection::HttpSql(adapter) => { @@ -700,9 +700,9 @@ pub async fn get_table_count( let query = build_count_query(&qualified, filter_ref); adapter.execute_query(&query).await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; - let qualified = build_qualified_table(schema.as_deref(), &table, "odbc"); + let qualified = build_qualified_table(schema.as_deref(), &table, "jdbc"); let query = build_count_query(&qualified, filter_ref); adapter.execute_query(&query).await } @@ -910,9 +910,9 @@ pub async fn update_table_row( .await .map_err(|e| format!("Failed to update row: {}", e))?; } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; - let sql = build_update_sql("odbc")?; + let sql = build_update_sql("jdbc")?; adapter .execute_query(&sql) .await @@ -1061,9 +1061,9 @@ pub async fn delete_table_row( .await .map_err(|e| format!("Failed to delete row: {}", e))?; } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; - let sql = build_delete_sql("odbc"); + let sql = build_delete_sql("jdbc"); adapter .execute_query(&sql) .await diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index f57df2f2..eacfddb7 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -41,7 +41,7 @@ pub async fn connect_server( let a = adapter.lock().await; a.test_connection().await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let a = adapter.lock().await; a.test_connection().await } @@ -70,7 +70,7 @@ pub async fn disconnect_server(id: String, state: State<'_, AppState>) -> Result ActiveConnection::SQLite(adapter) => adapter.lock().await.disconnect().await, ActiveConnection::DuckDb(adapter) => adapter.lock().await.disconnect().await, ActiveConnection::ClickHouse(adapter) => adapter.lock().await.disconnect().await, - ActiveConnection::Odbc(adapter) => adapter.lock().await.disconnect().await, + ActiveConnection::JdbcBridge(adapter) => adapter.lock().await.disconnect().await, ActiveConnection::HttpSql(adapter) => adapter.lock().await.disconnect().await, }; diff --git a/src-tauri/src/commands/helpers.rs b/src-tauri/src/commands/helpers.rs index 3d2d3e2a..61aad030 100644 --- a/src-tauri/src/commands/helpers.rs +++ b/src-tauri/src/commands/helpers.rs @@ -1,7 +1,7 @@ use crate::database::strategy::{resolve_effective_type, ConnectionStrategy, CoreDatabaseType}; use crate::database::{ clickhouse::ClickHouseAdapter, config::ConnectionConfig, duckdb::DuckDbAdapter, - http_sql::HttpSqlAdapter, odbc::OdbcAdapter, ConnectionStatus, DatabaseAdapter, + http_sql::HttpSqlAdapter, jdbc_bridge::JdbcBridgeAdapter, ConnectionStatus, DatabaseAdapter, }; use crate::database::{ mysql::MySQLAdapter, postgres::PostgresAdapter, sqlite::SQLiteAdapter, @@ -52,12 +52,22 @@ pub async fn create_and_connect_adapter( adapter.connect().await.map_err(|e| e.to_string())?; Ok(ActiveConnection::ClickHouse(Arc::new(Mutex::new(adapter)))) } + CoreDatabaseType::Oracle => { + #[cfg(feature = "oracle")] + { + let mut adapter = crate::database::OracleAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + return Ok(ActiveConnection::Oracle(Arc::new(Mutex::new(adapter)))); + } + #[cfg(not(feature = "oracle"))] + Err("Oracle support requires the 'oracle' feature: cargo build --features oracle".to_string()) + } _ => Err(format!("Native adapter not yet implemented for {:?}", core)), }, - ConnectionStrategy::Odbc => { - let mut adapter = OdbcAdapter::new(conn_config); + ConnectionStrategy::JdbcBridge => { + let mut adapter = JdbcBridgeAdapter::new(conn_config); adapter.connect().await.map_err(|e| e.to_string())?; - Ok(ActiveConnection::Odbc(Arc::new(Mutex::new(adapter)))) + Ok(ActiveConnection::JdbcBridge(Arc::new(Mutex::new(adapter)))) } ConnectionStrategy::Http => { let mut adapter = HttpSqlAdapter::new(conn_config); @@ -107,10 +117,20 @@ pub async fn test_connection( adapter.connect().await.map_err(|e| e.to_string())?; adapter.test_connection().await.map_err(|e| e.to_string()) } + CoreDatabaseType::Oracle => { + #[cfg(feature = "oracle")] + { + let mut adapter = crate::database::OracleAdapter::new(conn_config); + adapter.connect().await.map_err(|e| e.to_string())?; + return adapter.test_connection().await.map_err(|e| e.to_string()); + } + #[cfg(not(feature = "oracle"))] + Err("Oracle support requires the 'oracle' feature".to_string()) + } _ => Err("Native adapter not yet implemented".into()), }, - ConnectionStrategy::Odbc => { - let mut adapter = OdbcAdapter::new(conn_config); + ConnectionStrategy::JdbcBridge => { + let mut adapter = JdbcBridgeAdapter::new(conn_config); adapter.connect().await.map_err(|e| e.to_string())?; adapter.test_connection().await.map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index f91480ed..bacbb918 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -6,7 +6,7 @@ use crate::api_response::{db_error_to_api_error, ApiResponse}; use crate::database::{ ClickHouseAdapter, ConnectionConfig, DatabaseAdapter, DuckDbAdapter, HttpSqlAdapter, - MySQLAdapter, OdbcAdapter, PostgresAdapter, QueryResult, SqlServerAdapter, + JdbcBridgeAdapter, MySQLAdapter, PostgresAdapter, QueryResult, SqlServerAdapter, }; use crate::state::{ActiveConnection, AppState}; use serde::{Deserialize, Serialize}; @@ -82,7 +82,7 @@ pub async fn execute_query( SQLServer(ConnectionConfig), DuckDb(ConnectionConfig), ClickHouse(ConnectionConfig), - Odbc(ConnectionConfig), + JdbcBridge(ConnectionConfig), HttpSql(ConnectionConfig), } @@ -143,12 +143,12 @@ pub async fn execute_query( None } } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; if Some(db.as_str()) != adapter.config.database.as_deref() { let mut cfg = adapter.config.clone(); cfg.database = Some(db.clone()); - Some(TempKind::Odbc(cfg)) + Some(TempKind::JdbcBridge(cfg)) } else { None } @@ -185,7 +185,9 @@ pub async fn execute_query( TempKind::ClickHouse(cfg) => { execute_with_temp_adapter(ClickHouseAdapter::new(cfg), &sql).await } - TempKind::Odbc(cfg) => execute_with_temp_adapter(OdbcAdapter::new(cfg), &sql).await, + TempKind::JdbcBridge(cfg) => { + execute_with_temp_adapter(JdbcBridgeAdapter::new(cfg), &sql).await + } TempKind::HttpSql(cfg) => { execute_with_temp_adapter(HttpSqlAdapter::new(cfg), &sql).await } @@ -224,7 +226,7 @@ pub async fn execute_query( let adapter = adapter.lock().await; adapter.execute_query(&sql).await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; adapter.execute_query(&sql).await } @@ -336,7 +338,7 @@ pub async fn explain_query( let explain_sql = format!("EXPLAIN {}", sql); adapter.execute_query(&explain_sql).await } - ActiveConnection::Odbc(adapter) => { + ActiveConnection::JdbcBridge(adapter) => { let adapter = adapter.lock().await; let explain_sql = format!("EXPLAIN {}", sql); adapter.execute_query(&explain_sql).await diff --git a/src-tauri/src/database/config.rs b/src-tauri/src/database/config.rs index f83ae981..4686c50a 100644 --- a/src-tauri/src/database/config.rs +++ b/src-tauri/src/database/config.rs @@ -64,20 +64,20 @@ pub enum DatabaseType { /// 达梦 DM8 (MySQL mode, secondary) — MySQL wire protocol alias. DM8, - // ── ODBC bridge ── - /// Oracle Database — ODBC bridge / oracle-rs (optional feature). + // ── JDBC bridge (Java subprocess, lazy download) ── + /// Oracle Database — oracle-rs (native, optional feature). Oracle, - /// IBM DB2 — ODBC bridge. + /// IBM DB2 — JDBC bridge. DB2, - /// H2 — ODBC bridge. + /// H2 — JDBC bridge. H2, - /// Snowflake — ODBC bridge. + /// Snowflake — JDBC bridge. Snowflake, - /// 达梦 DM8 (Oracle mode, primary) — ODBC bridge with COMPATIBLE_MODE auto-detect. + /// 达梦 DM8 (Oracle mode, primary) — JDBC bridge. DM8Oracle, - /// 虚谷 XuguDB — ODBC bridge. + /// 虚谷 XuguDB — JDBC bridge. XuguDB, - /// 南大通用 GBase 8a — ODBC bridge. + /// 南大通用 GBase 8a — JDBC bridge. GBase8a, // ── HTTP SQL bridge ── diff --git a/src-tauri/src/database/jdbc_bridge/adapter.rs b/src-tauri/src/database/jdbc_bridge/adapter.rs new file mode 100644 index 00000000..dbae2bf8 --- /dev/null +++ b/src-tauri/src/database/jdbc_bridge/adapter.rs @@ -0,0 +1,357 @@ +//! JDBC bridge adapter — implements DatabaseAdapter by delegating to a Java subprocess. + +use crate::database::{ + adapter::DatabaseAdapter, + config::ConnectionConfig, + error::{DbError, DbResult}, + pool::ConnectionPool, + types::{ + ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, + }, +}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; + +use super::download::{self, bridge_jar_path, driver_class, build_jdbc_url, ensure_bridge_setup}; +use super::launcher::JdbcBridgeLauncher; +use super::pool::JdbcBridgePool; +use super::protocol::{ + ConnectParams, ConnectionStatusData, ExecuteQueryParams, JdbcMethod, JdbcRequest, ListParams, + QueryResultData, +}; + +/// JDBC bridge adapter. +/// +/// Spawns a Java subprocess (lazily) and communicates via JSON-RPC +/// over stdin/stdout. The Java side holds a HikariCP connection pool, +/// so connections are reused across queries. +pub struct JdbcBridgeAdapter { + pub config: ConnectionConfig, + launcher: Option>>, + conn_id: Option, +} + +impl JdbcBridgeAdapter { + pub fn new(config: ConnectionConfig) -> Self { + Self { + config, + launcher: None, + conn_id: None, + } + } + + /// Ensure all prerequisites are met: JRE, bridge JAR, and JDBC driver. + /// Downloads anything missing automatically (called once from `connect`). + async fn init_bridge(&mut self) -> DbResult>> { + let db_type = self.config.db_type; + + if !super::download::is_jre_installed() { + super::download::download_jre().await?; + } + + if !super::download::is_bridge_installed() { + super::download::download_bridge_plugin().await?; + } + + if !super::download::is_driver_available(db_type) { + super::download::download_driver(db_type).await?; + } + + let bridge_jar = super::download::bridge_jar_path(); + let mut launcher = JdbcBridgeLauncher::new(bridge_jar); + launcher.start()?; + let launcher = Arc::new(Mutex::new(launcher)); + + let url = super::download::build_jdbc_url( + db_type, + &self.config.host, + self.config.port, + self.config.database.as_deref(), + ); + let driver = super::download::driver_class(db_type); + + let result = Self::send_request( + &launcher, + JdbcRequest::new( + JdbcMethod::Connect, + serde_json::to_value(ConnectParams { + url, + username: self.config.username.clone(), + password: self.config.password.clone(), + database: self.config.database.clone(), + driver_class: driver.to_string(), + pool_min: 1, + pool_max: 5, + }) + .unwrap_or_default(), + ), + ) + .await?; + + self.conn_id = result + .as_str() + .map(|s| s.to_string()) + .or_else(|| Some(format!("conn_{}", uuid::Uuid::new_v4()))); + self.launcher = Some(launcher.clone()); + + Ok(launcher) + } + + /// Get the launcher (must be initialized first via `connect`). + fn launcher(&self) -> DbResult<&Arc>> { + self.launcher + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string())) + } + + async fn send_request( + launcher: &Arc>, + req: JdbcRequest, + ) -> DbResult { + let mut guard = launcher.lock().await; + let resp = guard.send_request(&req)?; + Ok(resp.result.unwrap_or(serde_json::Value::Null)) + } + + fn parse_query_result(data: serde_json::Value) -> DbResult { + let qr: QueryResultData = serde_json::from_value(data) + .map_err(|e| DbError::Connection(format!("Failed to parse query result: {}", e)))?; + + let rows: Vec = qr + .rows + .into_iter() + .map(|row| { + let mut map: HashMap = HashMap::new(); + for (i, val) in row.into_iter().enumerate() { + let col_name = qr.columns.get(i).cloned().unwrap_or_default(); + let qv = match val { + serde_json::Value::Null => QueryValue::Null, + serde_json::Value::String(s) => QueryValue::String(s), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + QueryValue::Int(i) + } else { + QueryValue::String(n.to_string()) + } + } + serde_json::Value::Bool(b) => QueryValue::String(b.to_string()), + _ => QueryValue::String(String::new()), + }; + map.insert(col_name, qv); + } + map + }) + .collect(); + + Ok(QueryResult { + columns: qr.columns, + rows, + rows_affected: qr.rows_affected, + execution_time_ms: None, + }) + } + + fn parse_connection_status(data: serde_json::Value) -> DbResult { + let cs: ConnectionStatusData = serde_json::from_value(data) + .map_err(|e| DbError::Connection(format!("Failed to parse status: {}", e)))?; + Ok(cs.into()) + } +} + +#[async_trait] +impl DatabaseAdapter for JdbcBridgeAdapter { + type Pool = JdbcBridgePool; + + async fn connect(&mut self) -> DbResult<()> { + self.init_bridge().await?; + Ok(()) + + } + + async fn disconnect(&mut self) -> DbResult<()> { + if let Some(launcher) = &self.launcher { + let mut guard = launcher.lock().await; + guard.shutdown(); + } + self.launcher = None; + self.conn_id = None; + Ok(()) + } + + async fn test_connection(&self) -> DbResult { + let launcher = self.launcher()?; + let data = Self::send_request( + &launcher, + JdbcRequest::new(JdbcMethod::TestConnection, serde_json::json!({ + "conn_id": self.conn_id, + })), + ) + .await?; + Self::parse_connection_status(data) + } + + async fn execute_query(&self, query: &str) -> DbResult { + let launcher = self.launcher()?; + let data = Self::send_request( + &launcher, + JdbcRequest::new(JdbcMethod::ExecuteQuery, serde_json::json!({ + "conn_id": self.conn_id, + "sql": query, + })), + ) + .await?; + Self::parse_query_result(data) + } + + async fn list_databases(&self) -> DbResult> { + let launcher = self.launcher()?; + let data = Self::send_request( + &launcher, + JdbcRequest::new(JdbcMethod::ListDatabases, serde_json::json!({ + "conn_id": self.conn_id, + })), + ) + .await?; + let names: Vec = serde_json::from_value(data) + .map_err(|e| DbError::Connection(format!("Failed to parse database list: {}", e)))?; + Ok(names + .into_iter() + .map(|name| DatabaseSchema { + name, + description: None, + is_system: false, + metadata: HashMap::new(), + }) + .collect()) + } + + async fn list_schemas(&self, database: Option<&str>) -> DbResult> { + let launcher = self.launcher()?; + let data = Self::send_request( + &launcher, + JdbcRequest::new(JdbcMethod::ListSchemas, serde_json::json!({ + "conn_id": self.conn_id, + "database": database, + })), + ) + .await?; + serde_json::from_value(data) + .map_err(|e| DbError::Connection(format!("Failed to parse schema list: {}", e))) + } + + async fn list_tables( + &self, + database: Option<&str>, + schema: Option<&str>, + ) -> DbResult> { + let launcher = self.launcher()?; + let data = Self::send_request( + &launcher, + JdbcRequest::new(JdbcMethod::ListTables, serde_json::json!({ + "conn_id": self.conn_id, + "database": database, + "schema": schema, + })), + ) + .await?; + let tables: Vec = serde_json::from_value(data) + .map_err(|e| DbError::Connection(format!("Failed to parse table list: {}", e)))?; + Ok(tables + .into_iter() + .filter_map(|t| { + Some(TableInfo { + schema: t.get("schema")?.as_str().map(|s| s.to_string()), + name: t.get("name")?.as_str()?.to_string(), + table_type: t + .get("table_type") + .and_then(|v| v.as_str()) + .unwrap_or("TABLE") + .to_string(), + row_count: t.get("row_count").and_then(|v| v.as_u64()), + size_bytes: None, + description: None, + metadata: HashMap::new(), + }) + }) + .collect()) + } + + async fn list_columns( + &self, + database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult> { + let launcher = self.launcher()?; + let data = Self::send_request( + &launcher, + JdbcRequest::new(JdbcMethod::ListColumns, serde_json::json!({ + "conn_id": self.conn_id, + "database": database, + "schema": schema, + "table": table, + })), + ) + .await?; + let cols: Vec = serde_json::from_value(data) + .map_err(|e| DbError::Connection(format!("Failed to parse column list: {}", e)))?; + Ok(cols + .into_iter() + .filter_map(|c| { + Some(ColumnInfo { + name: c.get("name")?.as_str()?.to_string(), + data_type: c + .get("data_type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + nullable: c + .get("nullable") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + default_value: c + .get("default_value") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + is_primary_key: c + .get("is_primary_key") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + is_auto_increment: c + .get("is_auto_increment") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + max_length: c.get("max_length").and_then(|v| v.as_u64().map(|x| x as u32)), + precision: c.get("precision").and_then(|v| v.as_u64().map(|x| x as u32)), + scale: c.get("scale").and_then(|v| v.as_u64().map(|x| x as u32)), + description: None, + metadata: HashMap::new(), + }) + }) + .collect()) + } + + async fn get_table_info( + &self, + database: Option<&str>, + schema: Option<&str>, + table: &str, + ) -> DbResult { + let tables = self.list_tables(database, schema).await?; + tables + .into_iter() + .find(|t| t.name == table) + .ok_or_else(|| DbError::Connection(format!("Table '{}' not found", table))) + } + + fn get_pool(&self) -> Option> { + None + } + + fn get_config(&self) -> &ConnectionConfig { + &self.config + } +} diff --git a/src-tauri/src/database/jdbc_bridge/download.rs b/src-tauri/src/database/jdbc_bridge/download.rs new file mode 100644 index 00000000..19cf6d26 --- /dev/null +++ b/src-tauri/src/database/jdbc_bridge/download.rs @@ -0,0 +1,290 @@ +//! JDBC bridge and driver download management. +//! +//! Downloads the bridge fat JAR, a minimal JRE, and per-database JDBC +//! driver JARs from GitHub Releases on demand. No system Java required. + +use crate::database::config::DatabaseType; +use crate::database::error::{DbError, DbResult}; +use std::path::PathBuf; + +/// Bridge JAR filename. +const BRIDGE_JAR: &str = "jdbc-bridge.jar"; + +/// Download URL base for bridge releases. +const BRIDGE_RELEASE_URL: &str = + "https://github.com/geek-fun/sqlkit/releases/latest/download"; + +/// Subdirectory under user home for bridge data. +const BRIDGE_DIR: &str = ".sqlkit/jdbc-bridge"; + +/// JRE subdirectory name. +const JRE_DIR: &str = "jre"; + +/// Java executable path relative to JRE root. +const JAVA_EXE: &str = if cfg!(target_os = "windows") { + "bin/java.exe" +} else { + "bin/java" +}; + +/// Get the bridge data directory (~/.sqlkit/jdbc-bridge). +fn bridge_dir() -> PathBuf { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(BRIDGE_DIR) +} + +/// Get the drivers directory. +fn drivers_dir() -> PathBuf { + bridge_dir().join("drivers") +} + +/// Get the path to the bridge JAR. +pub fn bridge_jar_path() -> PathBuf { + bridge_dir().join(BRIDGE_JAR) +} + +/// Get the path to the bundled JRE java binary. +pub fn jre_java_path() -> PathBuf { + bridge_dir().join(JRE_DIR).join(JAVA_EXE) +} + +/// Get the platform-specific JRE archive filename used in downloads. +fn jre_archive_name() -> &'static str { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { "jre-macos-aarch64.tar.gz" } + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { "jre-macos-x64.tar.gz" } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { "jre-linux-x64.tar.gz" } + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + { "jre-linux-aarch64.tar.gz" } + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + { "jre-windows-x64.zip" } + #[cfg(not(any( + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "windows", target_arch = "x86_64"), + )))] + { "" } +} + +/// Check if the bridge JAR is already installed. +pub fn is_bridge_installed() -> bool { + bridge_jar_path().exists() +} + +/// Check if the bundled JRE is installed. +pub fn is_jre_installed() -> bool { + jre_java_path().exists() +} + +/// Check if a JDBC driver is available for the given database type. +pub fn is_driver_available(db_type: DatabaseType) -> bool { + let name = driver_jar_name(db_type); + drivers_dir().join(name).exists() +} + +/// Ensure the bridge JAR and required driver are installed. +pub fn ensure_bridge_setup(db_type: DatabaseType) -> DbResult<()> { + let bridge = bridge_jar_path(); + if !bridge.exists() { + return Err(DbError::Connection(format!( + "JDBC bridge not installed. Run download_bridge_plugin() first. \ + Expected JAR at: {}", + bridge.display() + ))); + } + if !is_driver_available(db_type) { + return Err(DbError::Connection(format!( + "JDBC driver for {:?} not available. Run download_driver({:?}) first.", + db_type, db_type + ))); + } + Ok(()) +} + +/// Download the bridge fat JAR from GitHub Releases. +pub async fn download_bridge_plugin() -> DbResult<()> { + let dir = bridge_dir(); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| DbError::Connection(format!("Failed to create bridge dir: {}", e)))?; + + let url = format!("{}/{}", BRIDGE_RELEASE_URL, BRIDGE_JAR); + let response = reqwest::get(&url) + .await + .map_err(|e| DbError::Connection(format!("Failed to download bridge: {}", e)))?; + + let bytes = response + .bytes() + .await + .map_err(|e| DbError::Connection(format!("Failed to read bridge download: {}", e)))?; + + let path = bridge_jar_path(); + tokio::fs::write(&path, &bytes) + .await + .map_err(|e| DbError::Connection(format!("Failed to write bridge JAR: {}", e)))?; + + Ok(()) +} + +/// Download the bundled JRE for the current platform. +/// +/// The JRE is a minimal image built with `jlink` (only java.base + java.sql), +/// compressed as .tar.gz (macOS/Linux) or .zip (Windows). +pub async fn download_jre() -> DbResult<()> { + let archive_name = jre_archive_name(); + if archive_name.is_empty() { + return Err(DbError::Connection( + "No bundled JRE available for this platform".to_string(), + )); + } + + let dir = bridge_dir(); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| DbError::Connection(format!("Failed to create bridge dir: {}", e)))?; + + let url = format!("{}/jre/{}", BRIDGE_RELEASE_URL, archive_name); + let response = reqwest::get(&url) + .await + .map_err(|e| DbError::Connection(format!("Failed to download JRE: {}", e)))?; + + let bytes = response + .bytes() + .await + .map_err(|e| DbError::Connection(format!("Failed to read JRE download: {}", e)))?; + + let tmp_path = dir.join(format!("{}.tmp", archive_name)); + tokio::fs::write(&tmp_path, &bytes) + .await + .map_err(|e| DbError::Connection(format!("Failed to write JRE archive: {}", e)))?; + + let jre_path = dir.join(JRE_DIR); + if jre_path.exists() { + tokio::fs::remove_dir_all(&jre_path) + .await + .map_err(|e| DbError::Connection(format!("Failed to remove old JRE: {}", e)))?; + } + + let extract_result = tokio::task::spawn_blocking(move || -> Result<(), String> { + let file = std::fs::File::open(&tmp_path) + .map_err(|e| format!("Failed to open archive: {}", e))?; + + let jre_parent = dir.clone(); + if archive_name.ends_with(".tar.gz") { + let decoder = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + archive + .unpack(&jre_parent) + .map_err(|e| format!("Failed to extract JRE: {}", e))?; + } + + for entry in std::fs::read_dir(&jre_parent) + .map_err(|e| format!("Failed to list extracted files: {}", e))? + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + { + let bin_java = entry.path().join("bin").join(if cfg!(target_os = "windows") { "java.exe" } else { "java" }); + if bin_java.exists() { + let extracted_path = entry.path(); + let target_path = jre_parent.join(JRE_DIR); + std::fs::rename(&extracted_path, &target_path) + .map_err(|e| format!("Failed to rename JRE directory: {}", e))?; + break; + } + } + + let _ = std::fs::remove_file(&tmp_path); + Ok(()) + }) + .await + .map_err(|e| DbError::Connection(format!("JRE extraction panicked: {}", e)))?; + + extract_result.map_err(|e| DbError::Connection(format!("JRE extraction failed: {}", e)))?; + + Ok(()) +} + +/// Download a JDBC driver JAR for the given database type. +pub async fn download_driver(db_type: DatabaseType) -> DbResult<()> { + let dir = drivers_dir(); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| DbError::Connection(format!("Failed to create drivers dir: {}", e)))?; + + let jar_name = driver_jar_name(db_type); + let url = format!("{}/drivers/{}", BRIDGE_RELEASE_URL, jar_name); + + let response = reqwest::get(&url) + .await + .map_err(|e| DbError::Connection(format!("Failed to download driver: {}", e)))?; + + let bytes = response + .bytes() + .await + .map_err(|e| DbError::Connection(format!("Failed to read driver download: {}", e)))?; + + let path = drivers_dir().join(&jar_name); + tokio::fs::write(&path, &bytes) + .await + .map_err(|e| DbError::Connection(format!("Failed to write driver JAR: {}", e)))?; + + Ok(()) +} + +/// Map a DatabaseType to a JDBC driver JAR filename. +fn driver_jar_name(db_type: DatabaseType) -> &'static str { + use DatabaseType::*; + match db_type { + DB2 => "db2-jdbc.jar", + H2 => "h2-2.4.240.jar", + Snowflake => "snowflake-jdbc.jar", + DM8Oracle => "dm-jdbc.jar", + XuguDB => "xugudb-jdbc.jar", + GBase8a => "gbase8a-jdbc.jar", + _ => "unknown.jar", + } +} + +/// Map a DatabaseType to a JDBC driver class name. +pub fn driver_class(db_type: DatabaseType) -> &'static str { + use DatabaseType::*; + match db_type { + DB2 => "com.ibm.db2.jcc.DB2Driver", + H2 => "org.h2.Driver", + Snowflake => "net.snowflake.client.jdbc.SnowflakeDriver", + DM8Oracle => "dm.jdbc.driver.DmDriver", + XuguDB => "com.xugudb.jdbc.Driver", + GBase8a => "com.gbase.jdbc.Driver", + _ => "", + } +} + +/// Build a JDBC URL from connection config. +pub fn build_jdbc_url(db_type: DatabaseType, host: &str, port: u16, database: Option<&str>) -> String { + use DatabaseType::*; + let db = database.unwrap_or(""); + match db_type { + DB2 => format!("jdbc:db2://{}:{}/{}", host, port, db), + H2 => { + if db.is_empty() { + format!("jdbc:h2:tcp://{}:{}/~/.sqlkit/h2/{}", host, port, host) + } else { + format!("jdbc:h2:tcp://{}:{}/{}", host, port, db) + } + } + Snowflake => format!( + "jdbc:snowflake://{}.snowflakecomputing.com/?warehouse={}&db={}", + host, db, db + ), + DM8Oracle => format!("jdbc:dm://{}:{}", host, port), + XuguDB => format!("jdbc:xugudb://{}:{}/{}", host, port, db), + GBase8a => format!("jdbc:gbase://{}:{}/{}", host, port, db), + _ => format!("jdbc:unknown://{}:{}/{}", host, port, db), + } +} diff --git a/src-tauri/src/database/jdbc_bridge/launcher.rs b/src-tauri/src/database/jdbc_bridge/launcher.rs new file mode 100644 index 00000000..5945b3a6 --- /dev/null +++ b/src-tauri/src/database/jdbc_bridge/launcher.rs @@ -0,0 +1,170 @@ +//! Java bridge subprocess lifecycle management. +//! +//! Spawns a Java process and communicates with it via newline-delimited +//! JSON over stdin/stdout. + +use crate::database::error::{DbError, DbResult}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +use super::protocol::{JdbcRequest, JdbcResponse}; + +/// Default bridge JAR path relative to the app data directory. +const BRIDGE_JAR_NAME: &str = "jdbc-bridge.jar"; + +/// Manages the Java bridge subprocess. +pub struct JdbcBridgeLauncher { + process: Option, + stdin: Option, + jar_path: PathBuf, +} + +impl JdbcBridgeLauncher { + /// Create a new launcher that will use the given JAR path. + pub fn new(jar_path: PathBuf) -> Self { + Self { + process: None, + stdin: None, + jar_path, + } + } + + /// Get the Java executable path, preferring the bundled JRE. + pub fn detect_java() -> Option { + // Bundled JRE takes priority + let bundled = super::download::jre_java_path(); + if bundled.exists() { + return Some(bundled); + } + None + } + + /// Start the Java bridge process. + pub fn start(&mut self) -> DbResult<()> { + let java = Self::detect_java().ok_or_else(|| { + DbError::Connection( + "Bundled JRE not found. Call download_jre() first to install it." + .to_string(), + ) + })?; + + if !self.jar_path.exists() { + return Err(DbError::Connection(format!( + "JDBC bridge JAR not found at {}. Run download_bridge_plugin() first.", + self.jar_path.display() + ))); + } + + let mut child = Command::new(&java) + .args(["-jar", self.jar_path.to_str().unwrap_or("")]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| DbError::Connection(format!("Failed to start JDBC bridge: {}", e)))?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| DbError::Connection("Failed to capture bridge stdin".to_string()))?; + + self.process = Some(child); + self.stdin = Some(stdin); + + // Wait briefly and check if the process is still alive + std::thread::sleep(std::time::Duration::from_millis(500)); + if let Some(ref mut child) = self.process { + match child.try_wait() { + Ok(Some(status)) => { + return Err(DbError::Connection(format!( + "JDBC bridge exited immediately with code: {}", + status + ))); + } + Ok(None) => { /* still running, good */ } + Err(e) => { + return Err(DbError::Connection(format!( + "Error checking bridge process: {}", + e + ))); + } + } + } + + Ok(()) + } + + /// Send a request and receive a response. + pub fn send_request(&mut self, req: &JdbcRequest) -> DbResult { + let process = self.process.as_mut().ok_or_else(|| { + DbError::Connection("JDBC bridge not started".to_string()) + })?; + + let stdout = process.stdout.as_mut().ok_or_else(|| { + DbError::Connection("JDBC bridge stdout not available".to_string()) + })?; + + let stdin = self.stdin.as_mut().ok_or_else(|| { + DbError::Connection("JDBC bridge stdin not available".to_string()) + })?; + + // Serialize and write request line + let json = serde_json::to_string(req) + .map_err(|e| DbError::Connection(format!("Failed to serialize request: {}", e)))?; + + writeln!(stdin, "{}", json) + .map_err(|e| DbError::Connection(format!("Failed to write to bridge stdin: {}", e)))?; + stdin + .flush() + .map_err(|e| DbError::Connection(format!("Failed to flush bridge stdin: {}", e)))?; + + // Read response line + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| DbError::Connection(format!("Failed to read bridge response: {}", e)))?; + + if line.trim().is_empty() { + return Err(DbError::Connection( + "Empty response from JDBC bridge".to_string(), + )); + } + + let resp: JdbcResponse = serde_json::from_str(line.trim()) + .map_err(|e| DbError::Connection(format!("Failed to parse bridge response: {}", e)))?; + + if let Some(ref err) = resp.error { + return Err(DbError::Connection(format!("JDBC bridge error: {}", err))); + } + + Ok(resp) + } + + /// Check if the bridge process is still alive. + pub fn is_alive(&mut self) -> bool { + match self.process.as_mut() { + Some(child) => match child.try_wait() { + Ok(Some(_)) => false, + _ => true, + }, + None => false, + } + } + + /// Shutdown the bridge process gracefully. + pub fn shutdown(&mut self) { + if let Some(mut child) = self.process.take() { + let _ = child.kill(); + let _ = child.wait(); + } + self.stdin = None; + } +} + +impl Drop for JdbcBridgeLauncher { + fn drop(&mut self) { + self.shutdown(); + } +} diff --git a/src-tauri/src/database/jdbc_bridge/mod.rs b/src-tauri/src/database/jdbc_bridge/mod.rs new file mode 100644 index 00000000..a99f2b09 --- /dev/null +++ b/src-tauri/src/database/jdbc_bridge/mod.rs @@ -0,0 +1,27 @@ +//! JDBC bridge module. +//! +//! Provides database connectivity through a Java subprocess that +//! uses JDBC drivers. Communication is via JSON-RPC over stdin/stdout. +//! +//! # Architecture +//! +//! ```text +//! Rust (JdbcBridgeAdapter) ←→ stdin/stdout JSON-RPC ←→ Java process (HikariCP + JDBC) +//! ``` +//! +//! # Prerequisites +//! +//! 1. Java Runtime (JRE 17+) installed on the system +//! 2. `jdbc-bridge.jar` downloaded (via `download_bridge_plugin()`) +//! 3. JDBC driver JARs for target databases (downloaded automatically) + +pub mod adapter; +pub mod download; +pub mod launcher; +pub mod pool; +pub mod protocol; + +pub use adapter::JdbcBridgeAdapter; +pub use launcher::JdbcBridgeLauncher; +pub use pool::{JdbcBridgeConnection, JdbcBridgePool}; +pub use protocol::{JdbcMethod, JdbcRequest, JdbcResponse}; diff --git a/src-tauri/src/database/jdbc_bridge/pool.rs b/src-tauri/src/database/jdbc_bridge/pool.rs new file mode 100644 index 00000000..89ea5af2 --- /dev/null +++ b/src-tauri/src/database/jdbc_bridge/pool.rs @@ -0,0 +1,58 @@ +//! JDBC bridge connection pool stub. +//! +//! Connection pooling is handled by the Java side (HikariCP). +//! This pool is a pass-through stub that satisfies the crate's `ConnectionPool` trait. + +use crate::database::error::{DbError, DbResult}; +use crate::database::pool::ConnectionPool; +use async_trait::async_trait; +use std::sync::Arc; + +/// Dummy connection type for the JDBC bridge. +/// +/// Real connections are managed inside the Java process. +pub struct JdbcBridgeConnection; + +unsafe impl Send for JdbcBridgeConnection {} +unsafe impl Sync for JdbcBridgeConnection {} + +/// JDBC bridge connection pool stub. +/// +/// All connection management happens in the Java subprocess (HikariCP pool). +/// This stub exists only to satisfy the `ConnectionPool` trait bound. +pub struct JdbcBridgePool; + +#[async_trait] +impl ConnectionPool for JdbcBridgePool { + type Connection = JdbcBridgeConnection; + + async fn get_connection(&self) -> DbResult> { + Err(DbError::UnsupportedOperation( + "JDBC bridge connections are managed by the Java process".to_string(), + )) + } + + async fn return_connection(&self, _connection: Arc) -> DbResult<()> { + Ok(()) + } + + fn active_connections(&self) -> usize { + 0 + } + + fn idle_connections(&self) -> usize { + 0 + } + + fn max_connections(&self) -> usize { + 1 + } + + async fn close(&self) -> DbResult<()> { + Ok(()) + } + + async fn health_check(&self) -> DbResult<()> { + Ok(()) + } +} diff --git a/src-tauri/src/database/jdbc_bridge/protocol.rs b/src-tauri/src/database/jdbc_bridge/protocol.rs new file mode 100644 index 00000000..838b0171 --- /dev/null +++ b/src-tauri/src/database/jdbc_bridge/protocol.rs @@ -0,0 +1,123 @@ +//! JSON-RPC protocol types for the JDBC bridge. +//! +//! The bridge communicates with a Java subprocess over stdin/stdout +//! using newline-delimited JSON (one JSON object per line). + +use serde::{Deserialize, Serialize}; + +/// Request methods the Rust side can invoke on the Java bridge. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JdbcMethod { + Connect, + Disconnect, + ExecuteQuery, + ListDatabases, + ListSchemas, + ListTables, + ListColumns, + TestConnection, + Ping, +} + +/// A JSON-RPC request sent from Rust to Java. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JdbcRequest { + pub id: u64, + pub method: JdbcMethod, + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub params: serde_json::Value, +} + +impl JdbcRequest { + pub fn new(method: JdbcMethod, params: serde_json::Value) -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + Self { + id: NEXT_ID.fetch_add(1, Ordering::SeqCst), + method, + params, + } + } + + pub fn ping() -> Self { + Self::new(JdbcMethod::Ping, serde_json::Value::Null) + } +} + +/// A JSON-RPC response from Java to Rust. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JdbcResponse { + pub id: u64, + #[serde(default)] + pub result: Option, + #[serde(default)] + pub error: Option, +} + +// ── Connection parameters ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectParams { + pub url: String, + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, + pub driver_class: String, + #[serde(default = "default_pool_min")] + pub pool_min: u32, + #[serde(default = "default_pool_max")] + pub pool_max: u32, +} + +fn default_pool_min() -> u32 { 1 } +fn default_pool_max() -> u32 { 5 } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecuteQueryParams { + pub conn_id: String, + pub sql: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListParams { + pub conn_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub table: Option, +} + +// ── Response result types ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryResultData { + pub columns: Vec, + pub rows: Vec>, + #[serde(default)] + pub rows_affected: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionStatusData { + pub is_connected: bool, + pub server_version: Option, + pub current_database: Option, + pub current_user: Option, +} + +impl From for crate::database::types::ConnectionStatus { + fn from(d: ConnectionStatusData) -> Self { + Self { + is_connected: d.is_connected, + server_version: d.server_version, + current_database: d.current_database, + current_user: d.current_user, + metadata: std::collections::HashMap::new(), + } + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 7be97393..4a521e5d 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -65,9 +65,9 @@ pub mod config; pub mod duckdb; pub mod error; pub mod http_sql; +pub mod jdbc_bridge; pub mod manager; pub mod mysql; -pub mod odbc; pub mod pool; pub mod postgres; pub mod sqlite; @@ -87,9 +87,9 @@ pub use config::{ConnectionConfig, DatabaseType, PoolConfig, SslMode}; pub use duckdb::{DuckDbAdapter, DuckDbPool}; pub use error::{DbError, DbResult}; pub use http_sql::{HttpSqlAdapter, HttpSqlPool}; +pub use jdbc_bridge::{JdbcBridgeAdapter, JdbcBridgeLauncher, JdbcBridgePool}; pub use manager::{ConnectionManager, ConnectionMetadata, ManagerStats}; pub use mysql::{MySQLAdapter, MySQLPool}; -pub use odbc::{OdbcAdapter, OdbcConnection, OdbcPool}; pub use pool::{ConnectionPool, PoolStats}; pub use postgres::{PostgresAdapter, PostgresPool}; pub use sqlite::{SQLiteAdapter, SQLitePool}; @@ -97,3 +97,5 @@ pub use sqlserver::{SqlServerAdapter, SqlServerPool}; pub use types::{ ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, }; +#[cfg(feature = "oracle")] +pub use oracle::OracleAdapter; diff --git a/src-tauri/src/database/odbc.rs b/src-tauri/src/database/odbc.rs deleted file mode 100644 index 5834e5cf..00000000 --- a/src-tauri/src/database/odbc.rs +++ /dev/null @@ -1,828 +0,0 @@ -//! ODBC bridge adapter for enterprise databases. -//! -//! This module provides a concrete implementation of the `DatabaseAdapter` trait -//! using ODBC (Open Database Connectivity) to support enterprise databases -//! such as Oracle, IBM DB2, Snowflake, DM8 (Oracle mode), XuguDB, and GBase 8a. -//! -//! # Thread Safety -//! -//! The `odbc` crate provides synchronous, non-thread-safe (`!Send`, `!Sync`) types. -//! All ODBC operations are wrapped in `tokio::task::spawn_blocking()` so the -//! async runtime is never blocked and ODBC objects never cross thread boundaries. -//! -//! # COMPATIBLE_MODE Auto-Detection -//! -//! For databases with multiple SQL dialects (DM8, OceanBase), the adapter probes -//! the server after connection to determine which dialect is active. The detected -//! mode influences schema query syntax (e.g., Oracle-style `user_tables` vs -//! MySQL-style `information_schema`). - -use crate::database::{ - adapter::DatabaseAdapter, - config::{ConnectionConfig, DatabaseType}, - error::{DbError, DbResult}, - pool::ConnectionPool, - types::{ - ColumnInfo, ConnectionStatus, DatabaseSchema, QueryResult, QueryRow, QueryValue, TableInfo, - }, -}; -use async_trait::async_trait; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -// The odbc crate (v0.18) re-exports odbc_safe as both `safe` module and via `pub extern crate`. -// AutocommitOn, StatementState, etc. come from odbc_safe. -use odbc::safe::AutocommitOn; -use odbc::{ - create_environment_v3, Connection, Cursor, Data, NoData, Statement, -}; - -// ============================================================================ -// ODBC Connection Wrapper (Send-safe) -// ============================================================================ - -/// Dummy wrapper to satisfy the `ConnectionPool::Connection: Send` bound. -/// -/// The actual ODBC connection is not held here; it is created and consumed -/// entirely inside `spawn_blocking` closures. This type exists only for the -/// trait contract. -pub struct OdbcConnection; - -unsafe impl Send for OdbcConnection {} -unsafe impl Sync for OdbcConnection {} - -// ============================================================================ -// OdbcPool -// ============================================================================ - -/// ODBC connection pool. -/// -/// Stores the ODBC connection string and re-creates ODBC connections on demand -/// within `tokio::task::spawn_blocking` closures. Because `odbc::Connection` is -/// `!Send`, we cannot hold a persistent connection in the pool; instead every -/// operation opens a fresh connection, executes, and tears down. -pub struct OdbcPool { - conn_str: String, -} - -impl OdbcPool { - /// Create a new pool from an ODBC connection string. - pub fn new(conn_str: String) -> Self { - Self { conn_str } - } - - /// Build an ODBC connection string from `ConnectionConfig`. - /// - /// The resulting string follows the standard ODBC key=value format, - /// e.g. `Driver={Oracle in instantclient};Server=localhost;Port=1521;...` - pub fn build_connection_string(config: &ConnectionConfig) -> String { - let driver = config - .options - .get("driver") - .cloned() - .unwrap_or_else(|| Self::driver_name(config.db_type).to_string()); - - let mut parts = Vec::new(); - parts.push(format!("Driver={{{}}}", driver)); - parts.push(format!("Server={}", config.host)); - - if config.port > 0 { - parts.push(format!("Port={}", config.port)); - } - - if let Some(ref db) = config.database { - parts.push(format!("Database={}", db)); - } - - parts.push(format!("UID={}", config.username)); - - if let Some(ref pw) = config.password { - parts.push(format!("PWD={}", pw)); - } - - // Append user-supplied extra options (may override any of the above) - for (key, value) in &config.options { - let k = key.to_lowercase(); - if k == "driver" - || k == "server" - || k == "port" - || k == "database" - || k == "uid" - || k == "pwd" - { - continue; // already set above - } - parts.push(format!("{}={}", key, value)); - } - - parts.join(";") - } - - /// Select a best-guess ODBC driver name for a given database type. - fn driver_name(db_type: DatabaseType) -> &'static str { - match db_type { - DatabaseType::Oracle => "Oracle in instantclient", - DatabaseType::DB2 => "IBM DB2 ODBC DRIVER", - DatabaseType::H2 => "H2 ODBC Driver", - DatabaseType::Snowflake => "SnowflakeDSIIDriver", - DatabaseType::DM8Oracle => "DM8 ODBC DRIVER", - DatabaseType::XuguDB => "XuguDB ODBC Driver", - DatabaseType::GBase8a => "GBase 8a ODBC Driver", - _ => "ODBC Driver", - } - } - - /// Execute a closure that receives a fresh ODBC connection. - /// - /// The environment and connection are created inside the closure and dropped - /// when it returns. This ensures all `!Send` ODBC objects stay on a single - /// thread. - fn with_connection(&self, f: F) -> DbResult - where - F: FnOnce(&Connection<'_, AutocommitOn>) -> DbResult + Send, - T: Send, - { - let env = create_environment_v3().map_err(|e| { - DbError::Connection(format!("Failed to create ODBC environment: {:?}", e)) - })?; - let conn = env - .connect_with_connection_string(&self.conn_str) - .map_err(|e| DbError::Connection(format!("ODBC connection failed: {}", e)))?; - f(&conn) - } - - /// Execute a query and return the result set as `QueryResult`. - fn exec_query(&self, query: &str) -> DbResult { - self.with_connection(|conn| exec_direct_and_collect(conn, query)) - } - - /// Execute a scalar query (single row, single column) and return the value. - fn exec_scalar_string(&self, query: &str) -> DbResult> { - self.with_connection(|conn| { - let result = exec_direct_and_collect(conn, query)?; - let val = result - .rows - .first() - .and_then(|row| row.values().next()) - .and_then(|v| match v { - QueryValue::String(s) => Some(s.clone()), - QueryValue::Int(n) => Some(n.to_string()), - _ => None, - }); - Ok(val) - }) - } -} - -#[async_trait] -impl ConnectionPool for OdbcPool { - type Connection = OdbcConnection; - - async fn get_connection(&self) -> DbResult> { - // Individual connection access is not supported for ODBC because - // odbc::Connection is !Send. All operations go through spawn_blocking - // and create/destroy connections internally. - Err(DbError::UnsupportedOperation( - "ODBC connections are managed internally via spawn_blocking".to_string(), - )) - } - - async fn return_connection(&self, _connection: Arc) -> DbResult<()> { - Ok(()) - } - - fn active_connections(&self) -> usize { - 0 - } - - fn idle_connections(&self) -> usize { - 0 - } - - fn max_connections(&self) -> usize { - 1 - } - - async fn close(&self) -> DbResult<()> { - Ok(()) - } - - async fn health_check(&self) -> DbResult<()> { - self.with_connection(|conn| { - exec_direct_and_collect(conn, "SELECT 1")?; - Ok(()) - })?; - Ok(()) - } -} - -// ============================================================================ -// SQL Dialect Detection -// ============================================================================ - -/// Represents the SQL dialect to use for schema queries, -/// auto-detected from DM8 COMPATIBLE_MODE or OceanBase compatibility mode. -#[derive(Debug, Clone, PartialEq)] -enum SqlDialect { - Oracle, - MySql, - PostgreSql, - /// MSSQL / SQL Server - SqlServer, - /// Unknown or default — use generic ODBC catalog functions - Generic, -} - -impl SqlDialect { - /// Map a raw compatibility mode string to a dialect. - fn from_compatible_mode(mode: &str) -> Self { - let m = mode.trim().to_uppercase(); - match m.as_str() { - // DM8: 0=Oracle, 1=MySQL, 2=MSSQL, 3=PG - "0" | "ORACLE" => SqlDialect::Oracle, - "1" | "MYSQL" | "MARIADB" => SqlDialect::MySql, - "2" | "MSSQL" | "SQLSERVER" | "SQL SERVER" => SqlDialect::SqlServer, - "3" | "POSTGRESQL" | "POSTGRES" | "PG" => SqlDialect::PostgreSql, - _ => SqlDialect::Generic, - } - } - - /// Schema query for listing tables (schema → name, table_type). - fn tables_query(&self, schema: Option<&str>) -> String { - match self { - SqlDialect::Oracle => { - let owner = schema - .map(|s| s.to_uppercase()) - .unwrap_or_else(|| "USER".to_string()); - format!( - "SELECT table_name AS name, 'TABLE' AS table_type FROM all_tables WHERE owner = '{}' \ - UNION ALL \ - SELECT view_name AS name, 'VIEW' AS table_type FROM all_views WHERE owner = '{}'", - owner, owner - ) - } - SqlDialect::PostgreSql => { - let schema_filter = schema - .map(|s| format!("AND schemaname = '{}'", s)) - .unwrap_or_default(); - format!( - "SELECT tablename AS name, 'TABLE' AS table_type FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') {} \ - UNION ALL \ - SELECT viewname AS name, 'VIEW' AS table_type FROM pg_catalog.pg_views WHERE schemaname NOT IN ('pg_catalog', 'information_schema') {}", - schema_filter, schema_filter - ) - } - SqlDialect::SqlServer | SqlDialect::MySql => { - let schema_filter = schema - .map(|s| format!("AND table_schema = '{}'", s)) - .unwrap_or_default(); - format!( - "SELECT table_name AS name, table_type FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'sys', 'mysql', 'performance_schema') {}", - schema_filter - ) - } - SqlDialect::Generic => { - let schema_filter = schema - .map(|s| format!("AND table_schema = '{}'", s)) - .unwrap_or_default(); - format!( - "SELECT table_name AS name, table_type FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'sys', 'mysql') {}", - schema_filter - ) - } - } - } - - /// Schema query for listing columns of a given table. - fn columns_query(&self, schema: Option<&str>, table: &str) -> String { - match self { - SqlDialect::Oracle => { - let owner = schema - .map(|s| s.to_uppercase()) - .unwrap_or_else(|| "USER".to_string()); - format!( - "SELECT column_name, data_type, nullable, data_default, \ - CASE WHEN column_id IN (SELECT column_id FROM user_cons_columns WHERE constraint_name IN (SELECT constraint_name FROM user_constraints WHERE table_name = '{}' AND constraint_type = 'P')) THEN 1 ELSE 0 END AS is_pk \ - FROM all_tab_columns WHERE owner = '{}' AND table_name = '{}' ORDER BY column_id", - table.to_uppercase(), - owner, - table.to_uppercase() - ) - } - SqlDialect::PostgreSql => { - format!( - "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \ - CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_pk \ - FROM information_schema.columns c \ - LEFT JOIN (SELECT ku.column_name FROM information_schema.table_constraints tc \ - JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name \ - WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = '{}') pk \ - ON c.column_name = pk.column_name \ - WHERE c.table_name = '{}' ORDER BY c.ordinal_position", - table, table - ) - } - SqlDialect::SqlServer | SqlDialect::MySql => { - let schema_filter = schema - .map(|s| format!("AND c.table_schema = '{}'", s)) - .unwrap_or_default(); - format!( - "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \ - CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_pk \ - FROM information_schema.columns c \ - LEFT JOIN (SELECT ku.column_name FROM information_schema.table_constraints tc \ - JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name \ - WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = '{}') pk \ - ON c.column_name = pk.column_name \ - WHERE c.table_name = '{}' {} ORDER BY c.ordinal_position", - table, table, schema_filter - ) - } - SqlDialect::Generic => { - format!( - "SELECT c.column_name, c.data_type, c.is_nullable, c.column_default \ - FROM information_schema.columns c WHERE c.table_name = '{}' ORDER BY c.ordinal_position", - table - ) - } - } - } -} - -// ============================================================================ -// OdbcAdapter -// ============================================================================ - -/// ODBC database adapter. -/// -/// Supports any database with an ODBC driver by building a connection string -/// from the `ConnectionConfig` and executing SQL through ODBC. The adapter -/// auto-detects DM8 COMPATIBLE_MODE and OceanBase tenant compatibility to -/// switch SQL dialect for schema queries. -pub struct OdbcAdapter { - pub config: ConnectionConfig, - pool: Option>, - /// Detected SQL dialect, stored after `connect()` probes the server. - dialect: Arc>, -} - -impl OdbcAdapter { - /// Create a new ODBC adapter from configuration. - pub fn new(config: ConnectionConfig) -> Self { - Self { - config, - pool: None, - dialect: Arc::new(Mutex::new(SqlDialect::Generic)), - } - } - - /// Build the connection string from current config. - fn conn_str(&self) -> String { - OdbcPool::build_connection_string(&self.config) - } - - /// Detect DM8 COMPATIBLE_MODE or OceanBase tenant mode and update the dialect. - async fn detect_compatible_mode(&self) { - let pool = match self.pool.as_ref() { - Some(p) => p.clone(), - None => return, - }; - - // --- DM8 probe --- - { - let pool = pool.clone(); - let query = - "SELECT para_value FROM v$dm_ini WHERE para_name='COMPATIBLE_MODE'".to_string(); - match tokio::task::spawn_blocking(move || pool.exec_scalar_string(&query)).await { - Ok(Ok(Some(mode))) => { - let dialect = SqlDialect::from_compatible_mode(&mode); - if let Ok(mut d) = self.dialect.lock() { - *d = dialect; - } - return; // detected, done - } - _ => { /* v$dm_ini not available — not DM8, continue to OceanBase probe */ } - } - } - - // --- OceanBase probe --- - { - let pool = pool.clone(); - let query = "SELECT COMPATIBILITY_MODE FROM DBA_OB_TENANTS".to_string(); - match tokio::task::spawn_blocking(move || pool.exec_scalar_string(&query)).await { - Ok(Ok(Some(mode))) => { - let dialect = SqlDialect::from_compatible_mode(&mode); - if let Ok(mut d) = self.dialect.lock() { - *d = dialect; - } - } - _ => { /* not OceanBase either — keep Generic */ } - } - } - } - - /// Read the current dialect (non-blocking, copies from the mutex). - fn current_dialect(&self) -> SqlDialect { - self.dialect - .lock() - .map(|d| d.clone()) - .unwrap_or(SqlDialect::Generic) - } - - /// Execute a query via spawn_blocking. - async fn exec_query_async(&self, query: &str) -> DbResult { - let pool = self - .pool - .as_ref() - .ok_or_else(|| DbError::Connection("Not connected".to_string()))? - .clone(); - let sql = query.to_string(); - tokio::task::spawn_blocking(move || pool.exec_query(&sql)) - .await - .map_err(|e| DbError::Connection(format!("spawn_blocking join error: {}", e)))? - } - - /// Execute a scalar query via spawn_blocking. - async fn exec_scalar_async(&self, query: &str) -> DbResult> { - let pool = self - .pool - .as_ref() - .ok_or_else(|| DbError::Connection("Not connected".to_string()))? - .clone(); - let sql = query.to_string(); - tokio::task::spawn_blocking(move || pool.exec_scalar_string(&sql)) - .await - .map_err(|e| DbError::Connection(format!("spawn_blocking join error: {}", e)))? - } -} - -// ============================================================================ -// DatabaseAdapter impl -// ============================================================================ - -#[async_trait] -impl DatabaseAdapter for OdbcAdapter { - type Pool = OdbcPool; - - async fn connect(&mut self) -> DbResult<()> { - let conn_str = self.conn_str(); - let pool = OdbcPool::new(conn_str); - - // Verify connectivity before accepting - pool.health_check().await?; - - self.pool = Some(Arc::new(pool)); - - // Auto-detect DM8 COMPATIBLE_MODE / OceanBase tenant mode - self.detect_compatible_mode().await; - - Ok(()) - } - - async fn disconnect(&mut self) -> DbResult<()> { - if let Some(pool) = &self.pool { - pool.close().await?; - } - self.pool = None; - Ok(()) - } - - async fn test_connection(&self) -> DbResult { - let pool = self - .pool - .as_ref() - .ok_or_else(|| DbError::Connection("Not connected".to_string()))? - .clone(); - - tokio::task::spawn_blocking(move || -> DbResult { - pool.with_connection(|_conn| { - // ODBC does not provide a standard way to query server version - // without driver-specific SQL. We just report "connected". - Ok(ConnectionStatus { - is_connected: true, - server_version: Some("ODBC".to_string()), - current_database: None, - current_user: None, - metadata: HashMap::new(), - }) - }) - }) - .await - .map_err(|e| DbError::Connection(format!("spawn_blocking join error: {}", e)))? - } - - async fn execute_query(&self, query: &str) -> DbResult { - self.exec_query_async(query).await - } - - async fn list_databases(&self) -> DbResult> { - // List databases is not universally supported via ODBC without - // driver-specific queries. Return the configured database name as a - // single entry when available. - let databases = if let Some(ref db) = self.config.database { - vec![DatabaseSchema { - name: db.clone(), - description: Some("ODBC connection".to_string()), - is_system: false, - metadata: HashMap::new(), - }] - } else { - Vec::new() - }; - Ok(databases) - } - - async fn list_schemas(&self, _database: Option<&str>) -> DbResult> { - let query = match self.current_dialect() { - SqlDialect::Oracle => { - "SELECT username FROM all_users ORDER BY username".to_string() - } - SqlDialect::PostgreSql => { - "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog', 'information_schema') ORDER BY schema_name".to_string() - } - SqlDialect::MySql | SqlDialect::SqlServer | SqlDialect::Generic => { - "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name" - .to_string() - } - }; - - let result = self.exec_query_async(&query).await?; - let schemas: Vec = result - .rows - .iter() - .filter_map(|row| { - row.values().next().and_then(|v| match v { - QueryValue::String(s) => Some(s.clone()), - _ => None, - }) - }) - .collect(); - Ok(schemas) - } - - async fn list_tables( - &self, - _database: Option<&str>, - schema: Option<&str>, - ) -> DbResult> { - let dialect = self.current_dialect(); - let query = dialect.tables_query(schema); - let result = self.exec_query_async(&query).await?; - - let tables: Vec = result - .rows - .iter() - .filter_map(|row| { - let name = row.get("name").and_then(|v| match v { - QueryValue::String(s) => Some(s.clone()), - _ => None, - })?; - let table_type = row - .get("table_type") - .and_then(|v| match v { - QueryValue::String(s) => Some(s.clone()), - _ => None, - }) - .unwrap_or_else(|| "TABLE".to_string()); - - Some(TableInfo { - schema: schema.map(|s| s.to_string()), - name, - table_type, - row_count: None, - size_bytes: None, - description: None, - metadata: HashMap::new(), - }) - }) - .collect(); - - Ok(tables) - } - - async fn list_columns( - &self, - _database: Option<&str>, - schema: Option<&str>, - table: &str, - ) -> DbResult> { - let dialect = self.current_dialect(); - let query = dialect.columns_query(schema, table); - let result = self.exec_query_async(&query).await?; - - let columns: Vec = result - .rows - .iter() - .map(|row| { - let name = row - .get("column_name") - .and_then(|v| match v { - QueryValue::String(s) => Some(s.clone()), - _ => None, - }) - .unwrap_or_default(); - let data_type = row - .get("data_type") - .and_then(|v| match v { - QueryValue::String(s) => Some(s.clone()), - _ => None, - }) - .unwrap_or_else(|| "unknown".to_string()); - let nullable = row - .get("nullable") - .and_then(|v| match v { - QueryValue::String(s) => Some(s == "YES"), - _ => None, - }) - .unwrap_or(true); - let default_value = row.get("column_default").and_then(|v| match v { - QueryValue::String(s) => Some(s.clone()), - QueryValue::Null => None, - _ => None, - }); - let is_pk = row - .get("is_pk") - .and_then(|v| match v { - QueryValue::Int(n) => Some(*n > 0), - QueryValue::String(s) => Some(s == "true" || s == "1" || s == "YES"), - _ => None, - }) - .unwrap_or(false); - - ColumnInfo { - name, - data_type, - nullable, - default_value, - is_primary_key: is_pk, - is_auto_increment: false, - max_length: None, - precision: None, - scale: None, - description: None, - metadata: HashMap::new(), - } - }) - .collect(); - - Ok(columns) - } - - async fn get_table_info( - &self, - _database: Option<&str>, - schema: Option<&str>, - table: &str, - ) -> DbResult { - let dialect = self.current_dialect(); - let row_count_q = match dialect { - SqlDialect::Oracle => { - let owner = schema - .map(|s| s.to_uppercase()) - .unwrap_or_else(|| "USER".to_string()); - format!( - "SELECT num_rows FROM all_tables WHERE owner = '{}' AND table_name = '{}'", - owner, - table.to_uppercase() - ) - } - SqlDialect::PostgreSql => { - format!( - "SELECT n_live_tup FROM pg_stat_user_tables WHERE relname = '{}'", - table - ) - } - SqlDialect::MySql | SqlDialect::SqlServer | SqlDialect::Generic => { - format!( - "SELECT table_rows FROM information_schema.tables WHERE table_name = '{}'", - table - ) - } - }; - - let row_count = self - .exec_scalar_async(&row_count_q) - .await - .ok() - .flatten() - .and_then(|s| s.parse::().ok()); - - Ok(TableInfo { - schema: schema.map(|s| s.to_string()), - name: table.to_string(), - table_type: "TABLE".to_string(), - row_count, - size_bytes: None, - description: None, - metadata: HashMap::new(), - }) - } - - fn get_pool(&self) -> Option> { - self.pool.clone() - } - - fn get_config(&self) -> &ConnectionConfig { - &self.config - } -} - -// ============================================================================ -// ODBC Helper Functions (synchronous — called inside spawn_blocking) -// ============================================================================ - -/// Execute a SQL query directly on an ODBC connection and collect results into -/// a `QueryResult`. -fn exec_direct_and_collect( - conn: &Connection<'_, AutocommitOn>, - query: &str, -) -> DbResult { - let stmt = - Statement::with_parent(conn).map_err(|e| DbError::QueryExecution(format!("{}", e)))?; - - match stmt - .exec_direct(query) - .map_err(|e| DbError::QueryExecution(format!("{}", e)))? - { - Data(mut stmt) => { - // SELECT-like query — fetch rows - let num_cols = stmt - .num_result_cols() - .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; - - // Collect column names (ODBC columns are 1-indexed) - let mut columns = Vec::new(); - for idx in 1..=num_cols { - let desc = stmt - .describe_col(idx as u16) - .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; - columns.push(desc.name.to_string()); - } - - let mut rows = Vec::new(); - loop { - let cursor = stmt - .fetch() - .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; - match cursor { - Some(mut cursor) => { - let mut row: HashMap = HashMap::new(); - for (col_idx, col_name) in columns.iter().enumerate() { - let value = get_cell_value(&mut cursor, (col_idx + 1) as u16); - row.insert(col_name.clone(), value); - } - rows.push(row); - } - None => break, - } - } - - Ok(QueryResult { - columns, - rows, - rows_affected: None, - execution_time_ms: None, - }) - } - NoData(stmt) => { - // INSERT / UPDATE / DELETE — return affected row count - let rows_affected = stmt - .affected_row_count() - .map_err(|e| DbError::QueryExecution(format!("{}", e)))?; - Ok(QueryResult { - columns: Vec::new(), - rows: Vec::new(), - rows_affected: Some(rows_affected as u64), - execution_time_ms: None, - }) - } - } -} - -/// Extract a cell value from an ODBC cursor at the given column index (1-based). -/// -/// Tries String first (covers most data types via ODBC conversion), then binary -/// for BLOBs. If both fail or the column is NULL, returns `QueryValue::Null`. -/// Does NOT return an error so that a single bad cell does not break the entire -/// result set. -fn get_cell_value( - cursor: &mut Cursor<'_, '_, '_, S, AutocommitOn>, - col: u16, -) -> QueryValue { - // String conversion is the most universal — ODBC drivers can convert - // numeric, date, and text columns to strings. - match cursor.get_data::(col) { - Ok(Some(s)) => return QueryValue::String(s), - Ok(None) => return QueryValue::Null, - Err(_) => { /* try next format */ } - } - - // Binary fallback for BLOB / VARBINARY columns - match cursor.get_data::>(col) { - Ok(Some(b)) => return QueryValue::Bytes(b), - Ok(None) => return QueryValue::Null, - Err(_) => { /* give up */ } - } - - QueryValue::Null -} diff --git a/src-tauri/src/database/strategy.rs b/src-tauri/src/database/strategy.rs index d1040372..ea826158 100644 --- a/src-tauri/src/database/strategy.rs +++ b/src-tauri/src/database/strategy.rs @@ -28,8 +28,8 @@ pub enum CoreDatabaseType { pub enum ConnectionStrategy { /// Route to a native adapter via CoreDatabaseType. Native(CoreDatabaseType), - /// Route to ODBC bridge adapter. - Odbc, + /// Route to JDBC bridge adapter (Java subprocess). + JdbcBridge, /// Route to HTTP SQL bridge adapter. Http, } @@ -61,14 +61,14 @@ pub fn resolve_effective_type(db: DatabaseType) -> ConnectionStrategy { DuckDb => ConnectionStrategy::Native(CoreDatabaseType::DuckDb), ClickHouse => ConnectionStrategy::Native(CoreDatabaseType::ClickHouse), - // ODBC bridge - Oracle => ConnectionStrategy::Odbc, - DB2 => ConnectionStrategy::Odbc, - H2 => ConnectionStrategy::Odbc, - Snowflake => ConnectionStrategy::Odbc, - DM8Oracle => ConnectionStrategy::Odbc, - XuguDB => ConnectionStrategy::Odbc, - GBase8a => ConnectionStrategy::Odbc, + // JDBC bridge (Java subprocess) + Oracle => ConnectionStrategy::Native(CoreDatabaseType::Oracle), + DB2 => ConnectionStrategy::JdbcBridge, + H2 => ConnectionStrategy::JdbcBridge, + Snowflake => ConnectionStrategy::JdbcBridge, + DM8Oracle => ConnectionStrategy::JdbcBridge, + XuguDB => ConnectionStrategy::JdbcBridge, + GBase8a => ConnectionStrategy::JdbcBridge, // HTTP SQL bridge Trino | Presto => ConnectionStrategy::Http, @@ -157,9 +157,17 @@ mod tests { } #[test] - fn test_odbc_types() { + fn test_oracle_routes_to_native() { + assert_eq!( + resolve_effective_type(DatabaseType::Oracle), + ConnectionStrategy::Native(CoreDatabaseType::Oracle), + "Oracle should route to native Oracle adapter" + ); + } + + #[test] + fn test_jdbc_bridge_types() { for db in [ - DatabaseType::Oracle, DatabaseType::DB2, DatabaseType::H2, DatabaseType::Snowflake, @@ -169,8 +177,8 @@ mod tests { ] { assert_eq!( resolve_effective_type(db), - ConnectionStrategy::Odbc, - "{:?} should be ODBC bridge", + ConnectionStrategy::JdbcBridge, + "{:?} should be JDBC bridge", db ); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 792f4407..7d0b37d9 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -13,9 +13,11 @@ use uuid::Uuid; /// Core adapter types used in dispatch logic. use crate::database::{ clickhouse::ClickHouseAdapter, duckdb::DuckDbAdapter, http_sql::HttpSqlAdapter, - mysql::MySQLAdapter, odbc::OdbcAdapter, postgres::PostgresAdapter, sqlite::SQLiteAdapter, - sqlserver::SqlServerAdapter, + jdbc_bridge::JdbcBridgeAdapter, mysql::MySQLAdapter, postgres::PostgresAdapter, + sqlite::SQLiteAdapter, sqlserver::SqlServerAdapter, }; +#[cfg(feature = "oracle")] +use crate::database::OracleAdapter; /// Server configuration with connection details. /// @@ -155,7 +157,9 @@ pub enum ActiveConnection { SQLServer(Arc>), DuckDb(Arc>), ClickHouse(Arc>), - Odbc(Arc>), + #[cfg(feature = "oracle")] + Oracle(Arc>), + JdbcBridge(Arc>), HttpSql(Arc>), } diff --git a/src/components/connections/ServerFormDialog.vue b/src/components/connections/ServerFormDialog.vue index 5b89b5c5..37a3a0a6 100644 --- a/src/components/connections/ServerFormDialog.vue +++ b/src/components/connections/ServerFormDialog.vue @@ -18,7 +18,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useDatabaseIcon } from '@/composables/useDatabaseIcon' import { toast } from '@/composables/useNotifications' -import { DatabaseType, resolveDatabase } from '@/store' +import { DatabaseType, dbTypeToBackend, resolveDatabase } from '@/store' import { DEFAULT_SSL_MODE, sslModeToBackend, validateSslConfig } from '@/types/connection' import SslConfigSection from './ssl/SslConfigSection.vue' @@ -188,7 +188,7 @@ function handleDatabaseTypeChange(value: string) { if (!props.connection || formData.value.port === defaultPorts[props.connection.type]) { formData.value.port = defaultPorts[type] } - if (type === DatabaseType.SQLITE) { + if (type === DatabaseType.SQLITE || type === DatabaseType.DUCKDB) { formData.value.host = '' formData.value.port = 0 formData.value.username = '' @@ -198,13 +198,19 @@ function handleDatabaseTypeChange(value: string) { } } +const isFileBased = computed(() => + formData.value.type === DatabaseType.SQLITE + || formData.value.type === DatabaseType.DUCKDB, +) + // SQLite file picker function - handles both open existing and create new async function selectDatabaseFile() { try { const selected = await open({ multiple: false, filters: [ - { name: 'SQLite Database', extensions: ['db', 'sqlite', 'sqlite3'] }, + { name: 'SQLite', extensions: ['db', 'sqlite', 'sqlite3'] }, + { name: 'DuckDB', extensions: ['duckdb', 'db'] }, { name: 'All Files', extensions: ['*'] }, ], }) @@ -231,7 +237,7 @@ function validateForm(): boolean { errors.name = t('components.serverForm.errors.nameRequired') } - if (formData.value.type === DatabaseType.SQLITE) { + if (isFileBased.value) { if (formData.value.host !== ':memory:' && !formData.value.host.trim()) { errors.host = t('components.serverForm.errors.filePathRequired') } @@ -297,20 +303,7 @@ async function handleTestConnection() { } function mapDatabaseTypeToBackend(type: DatabaseType): string { - switch (type) { - case DatabaseType.POSTGRESQL: - return 'postgresql' - case DatabaseType.MYSQL: - return 'mysql' - case DatabaseType.MARIADB: - return 'mysql' - case DatabaseType.SQLITE: - return 'sqlite' - case DatabaseType.SQLSERVER: - return 'sqlserver' - default: - return 'postgresql' - } + return dbTypeToBackend[type] ?? 'PostgreSQL' } function handleSave() { @@ -326,8 +319,6 @@ function handleSave() { emit('save', { ...formData.value }) isOpen.value = false } - -const isSqlite = computed(() => formData.value.type === DatabaseType.SQLITE)