diff --git a/.gitignore b/.gitignore index 4fff20a..7c29546 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ dist-ssr devdiary docs coverage +plugins/**/target diff --git a/.rules/frontend.md b/.rules/frontend.md new file mode 100644 index 0000000..8d5f2af --- /dev/null +++ b/.rules/frontend.md @@ -0,0 +1,2 @@ +# Frontend Rules +1. **No Driver Conditions:** NEVER add driver-specific conditionals (e.g., `driver === "duckdb"`, `activeDriver === "sqlite"`) in frontend code. Driver-specific logic belongs in the backend (Rust driver trait implementations or plugin code). The frontend must remain driver-agnostic. diff --git a/plugins/duckdb/Cargo.lock b/plugins/duckdb/Cargo.lock new file mode 100644 index 0000000..1911242 --- /dev/null +++ b/plugins/duckdb/Cargo.lock @@ -0,0 +1,2563 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +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", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arrow" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e833808ff2d94ed40d9379848a950d995043c7fb3e81a30b383f4c6033821cc" +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 = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad08897b81588f60ba983e3ca39bda2b179bdd84dced378e7df81a5313802ef8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num", +] + +[[package]] +name = "arrow-array" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8548ca7c070d8db9ce7aa43f37393e4bfcf3f2d3681df278490772fd1673d08d" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.16.1", + "num", +] + +[[package]] +name = "arrow-buffer" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e003216336f70446457e280807a73899dd822feaf02087d31febca1363e2fccc" +dependencies = [ + "bytes", + "half", + "num", +] + +[[package]] +name = "arrow-cast" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919418a0681298d3a77d1a315f625916cb5678ad0d74b9c60108eb15fd083023" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c64fff1d142f833d78897a772f2e5b55b36cb3e6320376f0961ab0db7bd6d0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num", +] + +[[package]] +name = "arrow-ord" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8f82583eb4f8d84d4ee55fd1cb306720cddead7596edce95b50ee418edf66f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d07ba24522229d9085031df6b94605e0f4b26e099fb7cdeec37abd941a73753" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" +dependencies = [ + "bitflags", +] + +[[package]] +name = "arrow-select" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c41dbbd1e97bfcaee4fcb30e29105fb2c75e4d82ae4de70b792a5d3f66b2e7a" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num", +] + +[[package]] +name = "arrow-string" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f5183c150fbc619eede22b861ea7c0eebed8eaac0333eaa7f6da5205fd504d" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "comfy-table" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56" +dependencies = [ + "strum 0.26.3", + "strum_macros 0.26.4", + "unicode-width", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "duckdb" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8685352ce688883098b61a361e86e87df66fc8c444f4a2411e884c16d5243a65" +dependencies = [ + "arrow", + "cast", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libduckdb-sys", + "num-integer", + "rust_decimal", + "strum 0.27.2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[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 = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libduckdb-sys" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78bacb8933586cee3b550c39b610d314f9b7a48701ac7a914a046165a4ad8da" +dependencies = [ + "cc", + "flate2", + "pkg-config", + "reqwest", + "serde", + "serde_json", + "tar", + "vcpkg", + "zip", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "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-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tabularis-duckdb-plugin" +version = "0.1.0" +dependencies = [ + "base64", + "duckdb", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" + +[[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", +] diff --git a/plugins/duckdb/Cargo.toml b/plugins/duckdb/Cargo.toml new file mode 100644 index 0000000..72d1b40 --- /dev/null +++ b/plugins/duckdb/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tabularis-duckdb-plugin" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +duckdb = { version = "1.1.1", features = ["bundled"] } +log = "0.4" +base64 = "0.22" diff --git a/plugins/duckdb/manifest.json b/plugins/duckdb/manifest.json new file mode 100644 index 0000000..a3b4a58 --- /dev/null +++ b/plugins/duckdb/manifest.json @@ -0,0 +1,222 @@ +{ + "id": "duckdb", + "name": "DuckDB", + "version": "0.1.0", + "description": "DuckDB local database", + "default_port": null, + "executable": "tabularis-duckdb-plugin", + "capabilities": { + "schemas": false, + "views": true, + "routines": false, + "file_based": true, + "identifier_quote": "\"" + }, + "data_types": [ + { + "name": "BOOLEAN", + "category": "other", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TINYINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "SMALLINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "INTEGER", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "BIGINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "HUGEINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "UTINYINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "USMALLINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "UINTEGER", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "UBIGINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "FLOAT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "DOUBLE", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "DECIMAL", + "category": "numeric", + "requires_length": false, + "requires_precision": true + }, + { + "name": "VARCHAR", + "category": "string", + "requires_length": true, + "requires_precision": false, + "default_length": "255" + }, + { + "name": "TEXT", + "category": "string", + "requires_length": false, + "requires_precision": false + }, + { + "name": "BLOB", + "category": "binary", + "requires_length": false, + "requires_precision": false + }, + { + "name": "DATE", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TIME", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TIMESTAMP", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TIMESTAMP WITH TIME ZONE", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "INTERVAL", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "UUID", + "category": "string", + "requires_length": false, + "requires_precision": false + }, + { + "name": "JSON", + "category": "json", + "requires_length": false, + "requires_precision": false + }, + { + "name": "BIT", + "category": "other", + "requires_length": false, + "requires_precision": false + }, + { + "name": "ENUM", + "category": "string", + "requires_length": false, + "requires_precision": false + }, + { + "name": "LIST", + "category": "other", + "requires_length": false, + "requires_precision": false + }, + { + "name": "MAP", + "category": "other", + "requires_length": false, + "requires_precision": false + }, + { + "name": "STRUCT", + "category": "other", + "requires_length": false, + "requires_precision": false + }, + { + "name": "UNION", + "category": "other", + "requires_length": false, + "requires_precision": false + }, + { + "name": "UHUGEINT", + "category": "numeric", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TIMESTAMP_NS", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TIMESTAMP_MS", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TIMESTAMP_S", + "category": "date", + "requires_length": false, + "requires_precision": false + }, + { + "name": "TIME WITH TIME ZONE", + "category": "date", + "requires_length": false, + "requires_precision": false + } + ] +} \ No newline at end of file diff --git a/plugins/duckdb/src/bin/test.rs b/plugins/duckdb/src/bin/test.rs new file mode 100644 index 0000000..97849e3 --- /dev/null +++ b/plugins/duckdb/src/bin/test.rs @@ -0,0 +1,11 @@ +use duckdb::Connection; +fn main() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute("INSTALL json; LOAD json;", []).ok(); // In bundled, json is usually built-in or needs load + let mut stmt = conn.prepare("SELECT to_json(t) FROM (SELECT 1 as id, 'hello' as name, [1, 2, 3] as list) t").unwrap(); + let mut rows = stmt.query([]).unwrap(); + while let Some(row) = rows.next().unwrap() { + let json_str: String = row.get(0).unwrap(); + println!("{}", json_str); + } +} diff --git a/plugins/duckdb/src/bin/test_plugin.rs b/plugins/duckdb/src/bin/test_plugin.rs new file mode 100644 index 0000000..f7b272b --- /dev/null +++ b/plugins/duckdb/src/bin/test_plugin.rs @@ -0,0 +1,69 @@ +use std::process::{Command, Stdio}; +use std::io::{Write, BufReader, BufRead}; +use serde_json::json; + +fn main() { + let mut child = Command::new("cargo") + .args(&["run", "--manifest-path", "plugins/duckdb/Cargo.toml", "--bin", "tabularis-duckdb-plugin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to spawn plugin"); + + let mut stdin = child.stdin.take().expect("Failed to open stdin"); + let stdout = child.stdout.take().expect("Failed to open stdout"); + + // Spawn a thread to read stdout + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + println!("PLUGIN: {}", line.unwrap()); + } + }); + + let requests = vec![ + json!({ + "jsonrpc": "2.0", + "method": "execute_query", + "params": { + "query": "CREATE TABLE users (id INTEGER, name VARCHAR); INSERT INTO users VALUES (1, 'Alice'), (2, 'Bob');" + }, + "id": 1 + }), + json!({ + "jsonrpc": "2.0", + "method": "get_tables", + "params": {}, + "id": 2 + }), + json!({ + "jsonrpc": "2.0", + "method": "execute_query", + "params": { + "query": "SELECT * FROM users;" + }, + "id": 3 + }), + json!({ + "jsonrpc": "2.0", + "method": "execute_query", + "params": { + "query": "SELECT [1, 2, 3] as lst, {'a': 1, 'b': 2} as strct;" + }, + "id": 4 + }) + ]; + + for req in requests { + let mut req_str = serde_json::to_string(&req).unwrap(); + req_str.push('\n'); + println!("SENDING: {}", req_str.trim()); + stdin.write_all(req_str.as_bytes()).unwrap(); + stdin.flush().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + drop(stdin); + child.wait().unwrap(); +} diff --git a/plugins/duckdb/src/main.rs b/plugins/duckdb/src/main.rs new file mode 100644 index 0000000..b09d446 --- /dev/null +++ b/plugins/duckdb/src/main.rs @@ -0,0 +1,1348 @@ +use base64::Engine; +use duckdb::{types::Value, Connection}; +use serde_json::{json, Value as JsonValue}; +use std::collections::HashMap; +use std::io::{self, BufRead, Write}; + +fn get_or_create_connection<'a>( + connections: &'a mut HashMap, + db_path: &str, +) -> Result<&'a mut Connection, String> { + if !connections.contains_key(db_path) { + let conn = Connection::open(db_path).map_err(|e| e.to_string())?; + connections.insert(db_path.to_string(), conn); + } + Ok(connections.get_mut(db_path).unwrap()) +} + +fn main() { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let mut connections: HashMap = HashMap::new(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("Error reading from stdin: {}", e); + break; + } + }; + + if line.trim().is_empty() { + continue; + } + + let req: JsonValue = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to parse request: {}", e); + continue; + } + }; + + let id = req["id"].clone(); + let method = match req["method"].as_str() { + Some(m) => m.to_string(), + None => { + send_error(&mut stdout, id, -32600, "Method not specified"); + continue; + } + }; + + let params = &req["params"]; + + let db_path = params + .get("params") + .and_then(|p| p.get("database")) + .and_then(|d| d.as_str()) + .unwrap_or(":memory:") + .to_string(); + + let schema = params + .get("schema") + .and_then(|s| s.as_str()) + .unwrap_or("main") + .to_string(); + + let conn = match get_or_create_connection(&mut connections, &db_path) { + Ok(c) => c, + Err(e) => { + send_error( + &mut stdout, + id, + -32000, + &format!("Failed to connect to DuckDB: {}", e), + ); + continue; + } + }; + + match method.as_str() { + "test_connection" => { + send_success(&mut stdout, id, json!(true)); + } + "get_databases" => { + send_success(&mut stdout, id, json!(["main"])); + } + "get_schemas" => { + send_success(&mut stdout, id, json!(["main"])); + } + "get_tables" => match get_tables(conn, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32001, &e), + }, + "get_columns" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + match get_columns(conn, table, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32002, &e), + } + } + "get_foreign_keys" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + match get_foreign_keys(conn, table, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32003, &e), + } + } + "get_indexes" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + match get_indexes(conn, table, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32004, &e), + } + } + "get_views" => match get_views(conn, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32005, &e), + }, + "get_view_definition" => { + let view_name = params + .get("view_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + match get_view_definition(conn, view_name, &schema) { + Ok(v) => send_success(&mut stdout, id, json!(v)), + Err(e) => send_error(&mut stdout, id, -32006, &e), + } + } + "get_view_columns" => { + let view_name = params + .get("view_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + match get_columns(conn, view_name, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32007, &e), + } + } + "create_view" => { + let view_name = params + .get("view_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let definition = params + .get("definition") + .and_then(|d| d.as_str()) + .unwrap_or(""); + match create_view(conn, view_name, definition) { + Ok(()) => send_success(&mut stdout, id, json!(null)), + Err(e) => send_error(&mut stdout, id, -32008, &e), + } + } + "alter_view" => { + let view_name = params + .get("view_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let definition = params + .get("definition") + .and_then(|d| d.as_str()) + .unwrap_or(""); + match alter_view(conn, view_name, definition) { + Ok(()) => send_success(&mut stdout, id, json!(null)), + Err(e) => send_error(&mut stdout, id, -32009, &e), + } + } + "drop_view" => { + let view_name = params + .get("view_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + match drop_view(conn, view_name) { + Ok(()) => send_success(&mut stdout, id, json!(null)), + Err(e) => send_error(&mut stdout, id, -32010, &e), + } + } + "get_routines" | "get_routine_parameters" => { + send_success(&mut stdout, id, json!([])); + } + "get_routine_definition" => { + send_error( + &mut stdout, + id, + -32011, + "DuckDB does not support stored procedures", + ); + } + "execute_query" => { + let raw_query = params + .get("query") + .and_then(|q| q.as_str()) + .unwrap_or(""); + let query = inject_rowid_for_pk_less_tables(conn, raw_query, &schema); + let limit = params + .get("limit") + .and_then(|l| l.as_u64()) + .map(|l| l as u32); + let page = params + .get("page") + .and_then(|p| p.as_u64()) + .map(|p| p as u32) + .unwrap_or(1); + match execute_query(conn, &query, limit, page) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32012, &e), + } + } + "insert_record" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let data = params + .get("data") + .and_then(|d| d.as_object()) + .cloned() + .unwrap_or_default(); + let max_blob_size = params + .get("max_blob_size") + .and_then(|m| m.as_u64()) + .unwrap_or(100 * 1024 * 1024); + match insert_record(conn, table, data, max_blob_size) { + Ok(n) => send_success(&mut stdout, id, json!(n)), + Err(e) => send_error(&mut stdout, id, -32013, &e), + } + } + "update_record" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let pk_col = params + .get("pk_col") + .and_then(|p| p.as_str()) + .unwrap_or(""); + let pk_val = params + .get("pk_val") + .cloned() + .unwrap_or(JsonValue::Null); + let col_name = params + .get("col_name") + .and_then(|c| c.as_str()) + .unwrap_or(""); + let new_val = params + .get("new_val") + .cloned() + .unwrap_or(JsonValue::Null); + let max_blob_size = params + .get("max_blob_size") + .and_then(|m| m.as_u64()) + .unwrap_or(100 * 1024 * 1024); + match update_record(conn, table, pk_col, &pk_val, col_name, &new_val, max_blob_size) + { + Ok(n) => send_success(&mut stdout, id, json!(n)), + Err(e) => send_error(&mut stdout, id, -32014, &e), + } + } + "delete_record" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let pk_col = params + .get("pk_col") + .and_then(|p| p.as_str()) + .unwrap_or(""); + let pk_val = params + .get("pk_val") + .cloned() + .unwrap_or(JsonValue::Null); + match delete_record(conn, table, pk_col, &pk_val) { + Ok(n) => send_success(&mut stdout, id, json!(n)), + Err(e) => send_error(&mut stdout, id, -32015, &e), + } + } + "get_schema_snapshot" => match get_schema_snapshot(conn, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32016, &e), + }, + "get_all_columns_batch" => match get_all_columns_batch(conn, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32017, &e), + }, + "get_all_foreign_keys_batch" => match get_all_foreign_keys_batch(conn, &schema) { + Ok(v) => send_success(&mut stdout, id, v), + Err(e) => send_error(&mut stdout, id, -32018, &e), + }, + "get_create_table_sql" => { + let table_name = params.get("table_name").and_then(|t| t.as_str()).unwrap_or(""); + let columns: Vec = params.get("columns").and_then(|c| c.as_array()).cloned().unwrap_or_default(); + match ddl_get_create_table_sql(table_name, &columns) { + Ok(v) => send_success(&mut stdout, id, json!(v)), + Err(e) => send_error(&mut stdout, id, -32019, &e), + } + } + "get_add_column_sql" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let column = params.get("column").cloned().unwrap_or(json!({})); + match ddl_get_add_column_sql(table, &column) { + Ok(v) => send_success(&mut stdout, id, json!(v)), + Err(e) => send_error(&mut stdout, id, -32020, &e), + } + } + "get_alter_column_sql" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let old_column = params.get("old_column").cloned().unwrap_or(json!({})); + let new_column = params.get("new_column").cloned().unwrap_or(json!({})); + match ddl_get_alter_column_sql(table, &old_column, &new_column) { + Ok(v) => send_success(&mut stdout, id, json!(v)), + Err(e) => send_error(&mut stdout, id, -32021, &e), + } + } + "get_create_index_sql" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let index_name = params.get("index_name").and_then(|n| n.as_str()).unwrap_or(""); + let columns: Vec = params.get("columns").and_then(|c| c.as_array()).map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()).unwrap_or_default(); + let is_unique = params.get("is_unique").and_then(|u| u.as_bool()).unwrap_or(false); + match ddl_get_create_index_sql(table, index_name, &columns, is_unique) { + Ok(v) => send_success(&mut stdout, id, json!(v)), + Err(e) => send_error(&mut stdout, id, -32022, &e), + } + } + "get_create_foreign_key_sql" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let fk_name = params.get("fk_name").and_then(|n| n.as_str()).unwrap_or(""); + let column = params.get("column").and_then(|c| c.as_str()).unwrap_or(""); + let ref_table = params.get("ref_table").and_then(|t| t.as_str()).unwrap_or(""); + let ref_column = params.get("ref_column").and_then(|c| c.as_str()).unwrap_or(""); + let on_delete = params.get("on_delete").and_then(|d| d.as_str()); + let on_update = params.get("on_update").and_then(|u| u.as_str()); + match ddl_get_create_foreign_key_sql(table, fk_name, column, ref_table, ref_column, on_delete, on_update) { + Ok(v) => send_success(&mut stdout, id, json!(v)), + Err(e) => send_error(&mut stdout, id, -32023, &e), + } + } + "drop_index" => { + let index_name = params.get("index_name").and_then(|n| n.as_str()).unwrap_or(""); + let sql = format!("DROP INDEX {}", escape_identifier(index_name)); + match conn.execute_batch(&sql) { + Ok(_) => send_success(&mut stdout, id, json!(null)), + Err(e) => send_error(&mut stdout, id, -32024, &e.to_string()), + } + } + "drop_foreign_key" => { + let table = params.get("table").and_then(|t| t.as_str()).unwrap_or(""); + let fk_name = params.get("fk_name").and_then(|n| n.as_str()).unwrap_or(""); + let sql = format!("ALTER TABLE {} DROP CONSTRAINT {}", escape_identifier(table), escape_identifier(fk_name)); + match conn.execute_batch(&sql) { + Ok(_) => send_success(&mut stdout, id, json!(null)), + Err(e) => send_error(&mut stdout, id, -32025, &e.to_string()), + } + } + _ => { + send_error( + &mut stdout, + id, + -32601, + &format!("Method '{}' not implemented", method), + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// JSON-RPC helpers +// --------------------------------------------------------------------------- + +fn send_success(stdout: &mut io::Stdout, id: JsonValue, result: JsonValue) { + let response = json!({ + "jsonrpc": "2.0", + "result": result, + "id": id + }); + let mut res_str = serde_json::to_string(&response).unwrap(); + res_str.push('\n'); + stdout.write_all(res_str.as_bytes()).unwrap(); + stdout.flush().unwrap(); +} + +fn send_error(stdout: &mut io::Stdout, id: JsonValue, code: i32, message: &str) { + let response = json!({ + "jsonrpc": "2.0", + "error": { + "code": code, + "message": message + }, + "id": id + }); + let mut res_str = serde_json::to_string(&response).unwrap(); + res_str.push('\n'); + stdout.write_all(res_str.as_bytes()).unwrap(); + stdout.flush().unwrap(); +} + +// --------------------------------------------------------------------------- +// SQL utilities +// --------------------------------------------------------------------------- + +fn escape_identifier(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +// --------------------------------------------------------------------------- +// DDL generation helpers +// --------------------------------------------------------------------------- + +fn col_name(col: &JsonValue) -> &str { + col.get("name").and_then(|v| v.as_str()).unwrap_or("") +} +fn col_type(col: &JsonValue) -> &str { + col.get("data_type").and_then(|v| v.as_str()).unwrap_or("TEXT") +} +fn col_nullable(col: &JsonValue) -> bool { + col.get("is_nullable").and_then(|v| v.as_bool()).unwrap_or(true) +} +fn col_pk(col: &JsonValue) -> bool { + col.get("is_pk").and_then(|v| v.as_bool()).unwrap_or(false) +} +fn col_default(col: &JsonValue) -> Option<&str> { + col.get("default_value").and_then(|v| v.as_str()) +} + +fn ddl_get_create_table_sql(table_name: &str, columns: &[JsonValue]) -> Result, String> { + let mut col_defs = Vec::new(); + let mut pk_cols = Vec::new(); + for col in columns { + let mut def = format!("{} {}", escape_identifier(col_name(col)), col_type(col)); + if !col_nullable(col) { + def.push_str(" NOT NULL"); + } + if let Some(default) = col_default(col) { + def.push_str(&format!(" DEFAULT {}", default)); + } + col_defs.push(def); + if col_pk(col) { + pk_cols.push(escape_identifier(col_name(col))); + } + } + if !pk_cols.is_empty() { + col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); + } + Ok(vec![format!( + "CREATE TABLE {} (\n {}\n)", + escape_identifier(table_name), + col_defs.join(",\n ") + )]) +} + +fn ddl_get_add_column_sql(table: &str, column: &JsonValue) -> Result, String> { + let mut def = format!( + "ALTER TABLE {} ADD COLUMN {} {}", + escape_identifier(table), + escape_identifier(col_name(column)), + col_type(column) + ); + if !col_nullable(column) { + def.push_str(" NOT NULL"); + } + if let Some(default) = col_default(column) { + def.push_str(&format!(" DEFAULT {}", default)); + } + Ok(vec![def]) +} + +fn ddl_get_alter_column_sql(table: &str, old_col: &JsonValue, new_col: &JsonValue) -> Result, String> { + let tbl = escape_identifier(table); + let old_name = col_name(old_col); + let new_name = col_name(new_col); + let mut stmts = Vec::new(); + + if old_name != new_name { + stmts.push(format!( + "ALTER TABLE {} RENAME COLUMN {} TO {}", + tbl, escape_identifier(old_name), escape_identifier(new_name) + )); + } + + let col_ref = escape_identifier(new_name); + + if col_type(old_col) != col_type(new_col) { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} TYPE {}", + tbl, col_ref, col_type(new_col) + )); + } + + if col_nullable(old_col) != col_nullable(new_col) { + if col_nullable(new_col) { + stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", tbl, col_ref)); + } else { + stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", tbl, col_ref)); + } + } + + if col_default(old_col) != col_default(new_col) { + if let Some(default) = col_default(new_col) { + stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", tbl, col_ref, default)); + } else { + stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", tbl, col_ref)); + } + } + + if stmts.is_empty() { + return Err("No changes detected".into()); + } + Ok(stmts) +} + +fn ddl_get_create_index_sql(table: &str, index_name: &str, columns: &[String], is_unique: bool) -> Result, String> { + let unique = if is_unique { "UNIQUE " } else { "" }; + let cols: Vec = columns.iter().map(|c| escape_identifier(c)).collect(); + Ok(vec![format!( + "CREATE {}INDEX {} ON {} ({})", + unique, + escape_identifier(index_name), + escape_identifier(table), + cols.join(", ") + )]) +} + +fn ddl_get_create_foreign_key_sql( + table: &str, fk_name: &str, column: &str, + ref_table: &str, ref_column: &str, + on_delete: Option<&str>, on_update: Option<&str>, +) -> Result, String> { + let mut sql = format!( + "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", + escape_identifier(table), + escape_identifier(fk_name), + escape_identifier(column), + escape_identifier(ref_table), + escape_identifier(ref_column) + ); + if let Some(action) = on_delete { + sql.push_str(&format!(" ON DELETE {}", action)); + } + if let Some(action) = on_update { + sql.push_str(&format!(" ON UPDATE {}", action)); + } + Ok(vec![sql]) +} + +/// Formats a JSON value as a SQL literal suitable for inline use in DuckDB queries. +/// Uses `__USE_DEFAULT__` sentinel to emit `DEFAULT`, decodes blob wire format to hex. +fn sql_format_value(v: &JsonValue, max_blob_size: u64) -> Result { + match v { + JsonValue::Null => Ok("NULL".to_string()), + JsonValue::Bool(b) => Ok(if *b { "TRUE" } else { "FALSE" }.to_string()), + JsonValue::Number(n) => Ok(n.to_string()), + JsonValue::String(s) => { + if s == "__USE_DEFAULT__" { + return Ok("DEFAULT".to_string()); + } + if let Some(bytes) = decode_blob_wire_format(s, max_blob_size) { + let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); + return Ok(format!("decode('{}', 'hex')", hex)); + } + let escaped = s.replace('\'', "''"); + Ok(format!("'{}'", escaped)) + } + _ => Err(format!("Unsupported value type for SQL parameter: {:?}", v)), + } +} + +/// Decodes the Tabularis blob wire format ("BLOB:…" or "BLOB_FILE_REF:…") to raw bytes. +fn decode_blob_wire_format(value: &str, max_size: u64) -> Option> { + if value.starts_with("BLOB_FILE_REF:") { + let rest = value.strip_prefix("BLOB_FILE_REF:")?; + let parts: Vec<&str> = rest.splitn(3, ':').collect(); + if parts.len() != 3 { + return None; + } + let file_size: u64 = parts[0].parse().ok()?; + if file_size > max_size { + return None; + } + return std::fs::read(parts[2]).ok(); + } + + // Format: "BLOB:::" + let rest = value.strip_prefix("BLOB:")?; + let after_size = rest.splitn(2, ':').nth(1)?; + let base64_data = after_size.splitn(2, ':').nth(1)?; + base64::engine::general_purpose::STANDARD + .decode(base64_data) + .ok() +} + +fn extract_order_by(query: &str) -> String { + let upper = query.to_uppercase(); + if let Some(pos) = upper.rfind("ORDER BY") { + query[pos..].trim().to_string() + } else { + String::new() + } +} + +fn remove_order_by(query: &str) -> String { + let upper = query.to_uppercase(); + if let Some(pos) = upper.rfind("ORDER BY") { + query[..pos].trim().to_string() + } else { + query.to_string() + } +} + +/// Extracts a (possibly quoted) table name from the text immediately +/// following `FROM `. Returns `None` when the next token is `(` (subquery). +fn extract_table_name_after_from(s: &str) -> Option { + let s = s.trim_start(); + if s.starts_with('(') { + return None; // subquery, not a bare table + } + if s.starts_with('"') { + // Quoted identifier — find closing quote + let end = s[1..].find('"')?; + Some(s[1..1 + end].to_string()) + } else { + // Unquoted — take until whitespace, semicolon or closing paren + let end = s + .find(|c: char| c.is_whitespace() || c == ';' || c == ')') + .unwrap_or(s.len()); + if end == 0 { + return None; + } + Some(s[..end].to_string()) + } +} + +/// Rewrites `SELECT * FROM ` patterns so that `rowid` is included in +/// the result set for tables that lack an explicit primary key. +/// +/// Only bare-table references are rewritten; `SELECT * FROM (subquery)` is +/// left untouched because the outer projection inherits columns from the +/// inner query where `rowid` was already injected. +fn inject_rowid_for_pk_less_tables(conn: &Connection, query: &str, schema: &str) -> String { + let upper = query.to_uppercase(); + let pattern = "SELECT * FROM "; + + // Collect all match positions first. + let mut positions: Vec = Vec::new(); + let mut search_start = 0; + while let Some(rel_pos) = upper[search_start..].find(pattern) { + positions.push(search_start + rel_pos); + search_start += rel_pos + pattern.len(); + } + + if positions.is_empty() { + return query.to_string(); + } + + // Process in reverse so earlier byte offsets stay valid after insertion. + let mut result = query.to_string(); + let select_keyword_len = "SELECT ".len(); // 7 + + for &pos in positions.iter().rev() { + let after_from = &query[pos + pattern.len()..]; + let Some(table_name) = extract_table_name_after_from(after_from) else { + continue; + }; + let pks = get_primary_keys(conn, &table_name, schema); + if pks.is_empty() { + // Inject "rowid, " right after "SELECT " to form "SELECT rowid, * …" + let inject_pos = pos + select_keyword_len; + result.insert_str(inject_pos, "rowid, "); + } + } + + result +} + +// --------------------------------------------------------------------------- +// Schema inspection +// --------------------------------------------------------------------------- + +fn get_tables(conn: &Connection, schema: &str) -> Result { + let mut stmt = conn + .prepare( + "SELECT table_name \ + FROM information_schema.tables \ + WHERE table_schema = ? AND table_type = 'BASE TABLE' \ + ORDER BY table_name", + ) + .map_err(|e| e.to_string())?; + + let iter = stmt + .query_map([schema], |row| { + Ok(json!({ "name": row.get::<_, String>(0)? })) + }) + .map_err(|e| e.to_string())?; + + let mut tables = Vec::new(); + for t in iter { + tables.push(t.map_err(|e| e.to_string())?); + } + Ok(json!(tables)) +} + +/// Returns the set of primary-key column names for a table. +/// +/// Uses `PRAGMA table_info` which is more reliable than `duckdb_constraints()` +/// + `unnest()` — the latter can fail silently on certain DuckDB versions. +fn get_primary_keys( + conn: &Connection, + table_name: &str, + _schema: &str, +) -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + let query = format!( + "PRAGMA table_info('{}')", + table_name.replace('\'', "''") + ); + let Ok(mut stmt) = conn.prepare(&query) else { + return set; + }; + // PRAGMA table_info columns: cid, name, type, notnull, dflt_value, pk + let Ok(iter) = stmt.query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, i32>(5)?)) + }) else { + return set; + }; + for row in iter.flatten() { + if row.1 > 0 { + set.insert(row.0); + } + } + set +} + +/// Returns a map of table_name → set of PK column names for all tables in the schema. +/// +/// Iterates over all tables and uses `PRAGMA table_info` for each one, +/// matching the approach used by `get_primary_keys`. +fn get_all_primary_keys( + conn: &Connection, + schema: &str, +) -> HashMap> { + let mut result: HashMap> = HashMap::new(); + + // First, get all table names in the schema + let Ok(mut table_stmt) = conn.prepare( + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = ? AND table_type = 'BASE TABLE'", + ) else { + return result; + }; + let Ok(table_iter) = table_stmt.query_map([schema], |row| row.get::<_, String>(0)) else { + return result; + }; + + let table_names: Vec = table_iter.flatten().collect(); + for table_name in table_names { + let pks = get_primary_keys(conn, &table_name, schema); + if !pks.is_empty() { + result.insert(table_name, pks); + } + } + result +} + +fn get_columns( + conn: &Connection, + table_name: &str, + schema: &str, +) -> Result { + let pk_cols = get_primary_keys(conn, table_name, schema); + + let mut stmt = conn + .prepare( + "SELECT column_name, data_type, is_nullable, column_default \ + FROM information_schema.columns \ + WHERE table_name = ? AND table_schema = ? \ + ORDER BY ordinal_position", + ) + .map_err(|e| e.to_string())?; + + let col_iter = stmt + .query_map([table_name, schema], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut columns = Vec::new(); + let mut has_explicit_pk = false; + + for c in col_iter { + let (col_name, data_type, is_nullable, default_value) = + c.map_err(|e| e.to_string())?; + let is_pk = pk_cols.contains(&col_name); + if is_pk { + has_explicit_pk = true; + } + let is_auto_increment = data_type.to_uppercase().contains("SERIAL") + || default_value + .as_deref() + .map(|d| d.to_lowercase().contains("nextval")) + .unwrap_or(false); + + columns.push(json!({ + "name": col_name, + "data_type": data_type, + "is_pk": is_pk, + "is_nullable": is_nullable == "YES", + "is_auto_increment": is_auto_increment, + "default_value": default_value, + })); + } + + // DuckDB tables without an explicit PK still expose a virtual `rowid` + // column. Prepend it so the frontend can use it as a row identifier + // for cell editing. + if !has_explicit_pk { + columns.insert(0, json!({ + "name": "rowid", + "data_type": "BIGINT", + "is_pk": true, + "is_nullable": false, + "is_auto_increment": false, + "default_value": null, + })); + } + + Ok(json!(columns)) +} + +fn get_foreign_keys( + conn: &Connection, + table_name: &str, + schema: &str, +) -> Result { + let mut stmt = conn + .prepare( + "SELECT \ + unnest(constraint_column_names) as column_name, \ + referenced_table as ref_table, \ + unnest(referenced_column_names) as ref_column, \ + constraint_name as name \ + FROM duckdb_constraints() \ + WHERE table_name = ? AND constraint_type = 'FOREIGN KEY' AND schema_name = ?", + ) + .map_err(|e| e.to_string())?; + + let iter = stmt + .query_map([table_name, schema], |row| { + Ok(json!({ + "name": row.get::<_, String>(3)?, + "column_name": row.get::<_, String>(0)?, + "ref_table": row.get::<_, Option>(1)?.unwrap_or_default(), + "ref_column": row.get::<_, String>(2)?, + "on_delete": null, + "on_update": null, + })) + }) + .map_err(|e| e.to_string())?; + + let mut fks = Vec::new(); + for fk in iter { + match fk { + Ok(v) => fks.push(v), + Err(_) => continue, + } + } + Ok(json!(fks)) +} + +fn get_indexes( + conn: &Connection, + table_name: &str, + schema: &str, +) -> Result { + let mut stmt = match conn.prepare( + "SELECT index_name, unnest(expressions) as col_expr, is_unique, is_primary \ + FROM duckdb_indexes() \ + WHERE table_name = ? AND schema_name = ?", + ) { + Ok(s) => s, + Err(_) => return Ok(json!([])), + }; + + let iter = match stmt.query_map([table_name, schema], |row| { + Ok(json!({ + "name": row.get::<_, String>(0)?, + "column_name": row.get::<_, String>(1)?, + "is_unique": row.get::<_, bool>(2).unwrap_or(false), + "is_primary": row.get::<_, bool>(3).unwrap_or(false), + "seq_in_index": 0, + })) + }) { + Ok(i) => i, + Err(_) => return Ok(json!([])), + }; + + let mut indexes = Vec::new(); + for idx in iter { + match idx { + Ok(v) => indexes.push(v), + Err(_) => continue, + } + } + Ok(json!(indexes)) +} + +// --------------------------------------------------------------------------- +// Views +// --------------------------------------------------------------------------- + +fn get_views(conn: &Connection, schema: &str) -> Result { + let mut stmt = conn + .prepare( + "SELECT table_name \ + FROM information_schema.views \ + WHERE table_schema = ? \ + ORDER BY table_name", + ) + .map_err(|e| e.to_string())?; + + let iter = stmt + .query_map([schema], |row| { + Ok(json!({ + "name": row.get::<_, String>(0)?, + "definition": null, + })) + }) + .map_err(|e| e.to_string())?; + + let mut views = Vec::new(); + for v in iter { + views.push(v.map_err(|e| e.to_string())?); + } + Ok(json!(views)) +} + +fn get_view_definition( + conn: &Connection, + view_name: &str, + schema: &str, +) -> Result { + let mut stmt = conn + .prepare( + "SELECT view_definition \ + FROM information_schema.views \ + WHERE table_name = ? AND table_schema = ?", + ) + .map_err(|e| e.to_string())?; + + stmt.query_row([view_name, schema], |row| row.get::<_, String>(0)) + .map_err(|e| format!("Failed to get view definition: {}", e)) +} + +fn create_view(conn: &Connection, view_name: &str, definition: &str) -> Result<(), String> { + let sql = format!( + "CREATE VIEW {} AS {}", + escape_identifier(view_name), + definition + ); + conn.execute_batch(&sql) + .map_err(|e| format!("Failed to create view: {}", e)) +} + +fn alter_view(conn: &Connection, view_name: &str, definition: &str) -> Result<(), String> { + let drop_sql = format!("DROP VIEW IF EXISTS {}", escape_identifier(view_name)); + conn.execute_batch(&drop_sql) + .map_err(|e| format!("Failed to drop view: {}", e))?; + + let create_sql = format!( + "CREATE VIEW {} AS {}", + escape_identifier(view_name), + definition + ); + conn.execute_batch(&create_sql) + .map_err(|e| format!("Failed to recreate view: {}", e)) +} + +fn drop_view(conn: &Connection, view_name: &str) -> Result<(), String> { + let sql = format!("DROP VIEW IF EXISTS {}", escape_identifier(view_name)); + conn.execute_batch(&sql) + .map_err(|e| format!("Failed to drop view: {}", e)) +} + +// --------------------------------------------------------------------------- +// Query execution +// --------------------------------------------------------------------------- + +fn execute_query( + conn: &Connection, + query: &str, + limit: Option, + page: u32, +) -> Result { + let upper = query.trim_start().to_uppercase(); + let is_select = upper.starts_with("SELECT") + || upper.starts_with("WITH") + || upper.starts_with("SHOW") + || upper.starts_with("DESCRIBE") + || upper.starts_with("EXPLAIN") + || upper.starts_with("FROM"); + + if !is_select { + let mut stmt = conn.prepare(query).map_err(|e| e.to_string())?; + let affected = stmt.execute([]).map_err(|e| e.to_string())?; + return Ok(json!({ + "columns": [], + "rows": [], + "affected_rows": affected, + "truncated": false, + "pagination": null, + })); + } + + // SELECT with pagination + if let Some(l) = limit { + let page = if page == 0 { 1 } else { page }; + let offset = (page - 1) * l; + + let count_query = format!( + "SELECT COUNT(*) FROM ({}) AS __count_subq__", + query + ); + let total_rows: u64 = conn + .prepare(&count_query) + .and_then(|mut s| s.query_row([], |r| r.get::<_, i64>(0))) + .map(|n| n as u64) + .unwrap_or(0); + + let order_by = extract_order_by(query); + let paginated_query = if !order_by.is_empty() { + let inner = remove_order_by(query); + format!( + "SELECT * FROM ({}) AS __page_subq__ {} LIMIT {} OFFSET {}", + inner, order_by, l, offset + ) + } else { + format!( + "SELECT * FROM ({}) AS __page_subq__ LIMIT {} OFFSET {}", + query, l, offset + ) + }; + + let (columns, rows_data) = run_select(conn, &paginated_query)?; + + return Ok(json!({ + "columns": columns, + "rows": rows_data, + "affected_rows": 0, + "truncated": false, + "pagination": { + "page": page, + "page_size": l, + "total_rows": total_rows, + }, + })); + } + + // SELECT without pagination + let (columns, rows_data) = run_select(conn, query)?; + Ok(json!({ + "columns": columns, + "rows": rows_data, + "affected_rows": 0, + "truncated": false, + "pagination": null, + })) +} + +/// Runs a SELECT query and returns (column_names, rows). +fn run_select( + conn: &Connection, + query: &str, +) -> Result<(Vec, Vec), String> { + let mut stmt = conn.prepare(query).map_err(|e| e.to_string())?; + let mut rows = stmt.query([]).map_err(|e| e.to_string())?; + + let col_count = rows.as_ref().map(|s| s.column_count()).unwrap_or(0); + let column_names: Vec = rows + .as_ref() + .map(|s| s.column_names().into_iter().map(String::from).collect()) + .unwrap_or_default(); + + let mut rows_data: Vec = Vec::new(); + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let mut row_vec = Vec::new(); + for i in 0..col_count { + let val_ref = row.get_ref(i).map_err(|e| e.to_string())?; + let val = Value::from(val_ref); + row_vec.push(duckdb_value_to_json(val)); + } + rows_data.push(JsonValue::Array(row_vec)); + } + + Ok((column_names, rows_data)) +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +fn insert_record( + conn: &Connection, + table: &str, + data: serde_json::Map, + max_blob_size: u64, +) -> Result { + if data.is_empty() { + let sql = format!("INSERT INTO {} DEFAULT VALUES", escape_identifier(table)); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let affected = stmt.execute([]).map_err(|e| e.to_string())?; + return Ok(affected as u64); + } + + let cols: Vec = data.keys().map(|k| escape_identifier(k)).collect(); + let mut vals = Vec::new(); + for v in data.values() { + vals.push(sql_format_value(v, max_blob_size)?); + } + + let sql = format!( + "INSERT INTO {} ({}) VALUES ({})", + escape_identifier(table), + cols.join(", "), + vals.join(", ") + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let affected = stmt.execute([]).map_err(|e| e.to_string())?; + Ok(affected as u64) +} + +fn update_record( + conn: &Connection, + table: &str, + pk_col: &str, + pk_val: &JsonValue, + col_name: &str, + new_val: &JsonValue, + max_blob_size: u64, +) -> Result { + let val_sql = sql_format_value(new_val, max_blob_size)?; + let pk_sql = sql_format_value(pk_val, 0)?; + + let sql = format!( + "UPDATE {} SET {} = {} WHERE {} = {}", + escape_identifier(table), + escape_identifier(col_name), + val_sql, + escape_identifier(pk_col), + pk_sql + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let affected = stmt.execute([]).map_err(|e| e.to_string())?; + Ok(affected as u64) +} + +fn delete_record( + conn: &Connection, + table: &str, + pk_col: &str, + pk_val: &JsonValue, +) -> Result { + let pk_sql = sql_format_value(pk_val, 0)?; + + let sql = format!( + "DELETE FROM {} WHERE {} = {}", + escape_identifier(table), + escape_identifier(pk_col), + pk_sql + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let affected = stmt.execute([]).map_err(|e| e.to_string())?; + Ok(affected as u64) +} + +// --------------------------------------------------------------------------- +// Batch / snapshot +// --------------------------------------------------------------------------- + +fn get_all_columns_batch(conn: &Connection, schema: &str) -> Result { + let pks_by_table = get_all_primary_keys(conn, schema); + + let mut stmt = conn + .prepare( + "SELECT table_name, column_name, data_type, is_nullable, column_default \ + FROM information_schema.columns \ + WHERE table_schema = ? \ + ORDER BY table_name, ordinal_position", + ) + .map_err(|e| e.to_string())?; + + let iter = stmt + .query_map([schema], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut result: HashMap> = HashMap::new(); + for row in iter { + let (table_name, col_name, data_type, is_nullable, default_value) = + row.map_err(|e| e.to_string())?; + let pk_cols = pks_by_table + .get(&table_name) + .cloned() + .unwrap_or_default(); + let is_pk = pk_cols.contains(&col_name); + let is_auto_increment = data_type.to_uppercase().contains("SERIAL") + || default_value + .as_deref() + .map(|d| d.to_lowercase().contains("nextval")) + .unwrap_or(false); + + result.entry(table_name).or_default().push(json!({ + "name": col_name, + "data_type": data_type, + "is_pk": is_pk, + "is_nullable": is_nullable == "YES", + "is_auto_increment": is_auto_increment, + "default_value": default_value, + })); + } + Ok(json!(result)) +} + +fn get_all_foreign_keys_batch(conn: &Connection, schema: &str) -> Result { + let mut stmt = conn + .prepare( + "SELECT \ + table_name, \ + unnest(constraint_column_names) as column_name, \ + referenced_table as ref_table, \ + unnest(referenced_column_names) as ref_column, \ + constraint_name as name \ + FROM duckdb_constraints() \ + WHERE constraint_type = 'FOREIGN KEY' AND schema_name = ?", + ) + .map_err(|e| e.to_string())?; + + let iter = stmt + .query_map([schema], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?.unwrap_or_default(), + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut result: HashMap> = HashMap::new(); + for row in iter { + match row { + Ok((table_name, column_name, ref_table, ref_column, name)) => { + result.entry(table_name).or_default().push(json!({ + "name": name, + "column_name": column_name, + "ref_table": ref_table, + "ref_column": ref_column, + "on_delete": null, + "on_update": null, + })); + } + Err(_) => continue, + } + } + Ok(json!(result)) +} + +fn get_schema_snapshot(conn: &Connection, schema: &str) -> Result { + let tables_json = get_tables(conn, schema)?; + let table_names: Vec = tables_json + .as_array() + .unwrap_or(&Vec::new()) + .iter() + .filter_map(|t| t["name"].as_str().map(String::from)) + .collect(); + + let mut snapshots = Vec::new(); + for name in &table_names { + let columns = get_columns(conn, name, schema)?; + let fks = get_foreign_keys(conn, name, schema)?; + snapshots.push(json!({ + "name": name, + "columns": columns, + "foreign_keys": fks, + })); + } + Ok(json!(snapshots)) +} + +// --------------------------------------------------------------------------- +// Value conversion +// --------------------------------------------------------------------------- + +fn duckdb_value_to_json(val: Value) -> JsonValue { + match val { + Value::Null => JsonValue::Null, + Value::Boolean(b) => json!(b), + Value::TinyInt(v) => json!(v), + Value::SmallInt(v) => json!(v), + Value::Int(v) => json!(v), + Value::BigInt(v) => json!(v), + Value::HugeInt(v) => json!(v.to_string()), + Value::UTinyInt(v) => json!(v), + Value::USmallInt(v) => json!(v), + Value::UInt(v) => json!(v), + Value::UBigInt(v) => json!(v), + Value::Float(v) => json!(v), + Value::Double(v) => json!(v), + Value::Decimal(v) => json!(v.to_string()), + Value::Timestamp(_, t) => json!(t), + Value::Text(v) => json!(v), + Value::Blob(v) => json!(format!("Blob({} bytes)", v.len())), + Value::Date32(v) => json!(v), + Value::Time64(_, t) => json!(t), + Value::Interval { + months, + days, + nanos, + } => json!(format!("Interval({}m, {}d, {}ns)", months, days, nanos)), + Value::List(vals) => { + JsonValue::Array(vals.into_iter().map(duckdb_value_to_json).collect()) + } + Value::Enum(v) => json!(v), + Value::Struct(map) => { + let mut obj = serde_json::Map::new(); + for (k, v) in map.iter() { + obj.insert(k.clone(), duckdb_value_to_json(v.clone())); + } + JsonValue::Object(obj) + } + Value::Array(vals) => { + JsonValue::Array(vals.into_iter().map(duckdb_value_to_json).collect()) + } + Value::Map(map) => { + let mut obj = serde_json::Map::new(); + for (k, v) in map.iter() { + let key_str = match duckdb_value_to_json(k.clone()) { + JsonValue::String(s) => s, + other => other.to_string(), + }; + obj.insert(key_str, duckdb_value_to_json(v.clone())); + } + JsonValue::Object(obj) + } + Value::Union(v) => duckdb_value_to_json(*v), + } +} diff --git a/plugins/sync.sh b/plugins/sync.sh new file mode 100755 index 0000000..a7d5c79 --- /dev/null +++ b/plugins/sync.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Builds all plugins in this directory and installs them into +# the Tabularis plugins folder for the current OS. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Resolve the Tabularis plugins directory based on OS +case "$(uname -s)" in + Linux*) + PLUGINS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins" + ;; + Darwin*) + PLUGINS_DIR="$HOME/Library/Application Support/com.debba.tabularis/plugins" + ;; + MINGW*|MSYS*|CYGWIN*) + PLUGINS_DIR="${APPDATA}/com.debba.tabularis/plugins" + ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +echo "Target plugins directory: $PLUGINS_DIR" + +# Process each subdirectory that contains a manifest.json +for plugin_src in "$SCRIPT_DIR"/*/; do + manifest="$plugin_src/manifest.json" + if [[ ! -f "$manifest" ]]; then + continue + fi + + plugin_id=$(grep -o '"id"\s*:\s*"[^"]*"' "$manifest" | head -1 | sed 's/.*: *"\(.*\)"/\1/') + executable=$(grep -o '"executable"\s*:\s*"[^"]*"' "$manifest" | head -1 | sed 's/.*: *"\(.*\)"/\1/') + + if [[ -z "$plugin_id" || -z "$executable" ]]; then + echo " [SKIP] $plugin_src — could not parse manifest.json" >&2 + continue + fi + + echo "" + echo "==> Plugin: $plugin_id" + + # Build if it's a Rust crate + if [[ -f "$plugin_src/Cargo.toml" ]]; then + echo " Building (cargo build --release)..." + cargo build --release --manifest-path "$plugin_src/Cargo.toml" + fi + + dest_dir="$PLUGINS_DIR/$plugin_id" + mkdir -p "$dest_dir" + + # Copy manifest + cp "$manifest" "$dest_dir/manifest.json" + echo " Copied manifest.json" + + # Find and copy the compiled executable + # Look in the plugin's own target/release first, then the workspace target/release + bin_paths=( + "$plugin_src/target/release/$executable" + "$SCRIPT_DIR/../src-tauri/target/release/$executable" + "$SCRIPT_DIR/target/release/$executable" + ) + + copied=false + for bin_path in "${bin_paths[@]}"; do + if [[ -f "$bin_path" ]]; then + cp "$bin_path" "$dest_dir/$executable" + chmod +x "$dest_dir/$executable" + echo " Copied executable: $executable" + copied=true + break + fi + done + + if [[ "$copied" == false ]]; then + echo " [WARN] Executable '$executable' not found. Build may have failed." >&2 + fi + + echo " Installed to: $dest_dir" +done + +echo "" +echo "Sync complete. Restart Tabularis to load updated plugins." diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 18fb5d8..0042ffb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5825,7 +5825,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.8.14" +version = "0.8.15" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6f011ec..bdc33ff 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,25 +1,30 @@ -use sqlx::any::AnyConnectOptions; -use sqlx::{AnyConnection, Connection}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; -use std::str::FromStr; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Manager, Runtime, State}; use tokio::task::AbortHandle; use urlencoding::encode; use uuid::Uuid; -use crate::drivers::{mysql, postgres, sqlite}; use crate::keychain_utils; use crate::models::{ - ConnectionParams, ForeignKey, Index, QueryResult, RoutineInfo, RoutineParameter, - SavedConnection, SshConnection, SshConnectionInput, SshTestParams, TableColumn, TableInfo, - TestConnectionRequest, + ColumnDefinition, ConnectionParams, ForeignKey, Index, QueryResult, RoutineInfo, + RoutineParameter, SavedConnection, SshConnection, SshConnectionInput, SshTestParams, + TableColumn, TableInfo, TestConnectionRequest, }; use crate::ssh_tunnel::{get_tunnels, SshTunnel}; // Constants +/// Resolve the driver from the registry or return a descriptive error. +async fn driver_for( + id: &str, +) -> Result, String> { + crate::drivers::registry::get_driver(id) + .await + .ok_or_else(|| format!("Unsupported driver: {}", id)) +} + const DEFAULT_MYSQL_PORT: u16 = 3306; const DEFAULT_POSTGRES_PORT: u16 = 5432; @@ -231,12 +236,8 @@ pub async fn get_schemas( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_schemas(¶ms).await, - "postgres" => postgres::get_schemas(¶ms).await, - "sqlite" => sqlite::get_schemas(¶ms).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_schemas(¶ms).await } #[tauri::command] @@ -251,12 +252,8 @@ pub async fn get_routines( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_routines(¶ms).await, - "postgres" => postgres::get_routines(¶ms, schema.as_deref().unwrap_or("public")).await, - "sqlite" => sqlite::get_routines(¶ms).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_routines(¶ms, schema.as_deref()).await } #[tauri::command] @@ -276,19 +273,8 @@ pub async fn get_routine_parameters( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_routine_parameters(¶ms, &routine_name).await, - "postgres" => { - postgres::get_routine_parameters( - ¶ms, - &routine_name, - schema.as_deref().unwrap_or("public"), - ) - .await - } - "sqlite" => sqlite::get_routine_parameters(¶ms, &routine_name).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_routine_parameters(¶ms, &routine_name, schema.as_deref()).await } #[tauri::command] @@ -310,20 +296,8 @@ pub async fn get_routine_definition( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_routine_definition(¶ms, &routine_name, &routine_type).await, - "postgres" => { - postgres::get_routine_definition( - ¶ms, - &routine_name, - &routine_type, - schema.as_deref().unwrap_or("public"), - ) - .await - } - "sqlite" => sqlite::get_routine_definition(¶ms, &routine_name, &routine_type).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_routine_definition(¶ms, &routine_name, &routine_type, schema.as_deref()).await } #[tauri::command] @@ -335,63 +309,8 @@ pub async fn get_schema_snapshot( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - let driver = saved_conn.params.driver.clone(); - let pg_schema = schema.as_deref().unwrap_or("public"); - - // 1. Get Tables - let tables = match driver.as_str() { - "mysql" => mysql::get_tables(¶ms).await, - "postgres" => postgres::get_tables(¶ms, pg_schema).await, - "sqlite" => sqlite::get_tables(¶ms).await, - _ => Err("Unsupported driver".into()), - }?; - - // 2. Fetch ALL columns and foreign keys in batch (2 queries instead of N*2) - let result = match driver.as_str() { - "mysql" => { - let mut columns_map = mysql::get_all_columns_batch(¶ms).await?; - let mut fks_map = mysql::get_all_foreign_keys_batch(¶ms).await?; - - tables - .into_iter() - .map(|table| crate::models::TableSchema { - name: table.name.clone(), - columns: columns_map.remove(&table.name).unwrap_or_default(), - foreign_keys: fks_map.remove(&table.name).unwrap_or_default(), - }) - .collect() - } - "postgres" => { - let mut columns_map = postgres::get_all_columns_batch(¶ms, pg_schema).await?; - let mut fks_map = postgres::get_all_foreign_keys_batch(¶ms, pg_schema).await?; - - tables - .into_iter() - .map(|table| crate::models::TableSchema { - name: table.name.clone(), - columns: columns_map.remove(&table.name).unwrap_or_default(), - foreign_keys: fks_map.remove(&table.name).unwrap_or_default(), - }) - .collect() - } - "sqlite" => { - let table_names: Vec = tables.iter().map(|t| t.name.clone()).collect(); - let mut columns_map = sqlite::get_all_columns_batch(¶ms, &table_names).await?; - let mut fks_map = sqlite::get_all_foreign_keys_batch(¶ms, &table_names).await?; - - tables - .into_iter() - .map(|table| crate::models::TableSchema { - name: table.name.clone(), - columns: columns_map.remove(&table.name).unwrap_or_default(), - foreign_keys: fks_map.remove(&table.name).unwrap_or_default(), - }) - .collect() - } - _ => return Err("Unsupported driver".into()), - }; - - Ok(result) + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_schema_snapshot(¶ms, schema.as_deref()).await } #[tauri::command] @@ -1043,14 +962,8 @@ pub async fn test_connection( resolved_params.port ); - let url = build_connection_url(&resolved_params)?; - log::debug!("Connection URL: {}", url); - - let options = AnyConnectOptions::from_str(&url).map_err(|e| e.to_string())?; - let mut conn = AnyConnection::connect_with(&options) - .await - .map_err(|e: sqlx::Error| e.to_string())?; - conn.ping().await.map_err(|e: sqlx::Error| e.to_string())?; + let drv = driver_for(&resolved_params.driver).await?; + drv.test_connection(&resolved_params).await?; log::info!( "Connection test successful for database: {}", @@ -1162,8 +1075,8 @@ mod tests { } } - #[test] - fn test_mysql_url_basic() { + #[tokio::test] + async fn test_mysql_url_basic() { let params = create_params( "mysql", "localhost", @@ -1172,12 +1085,12 @@ mod tests { Some("secret"), "testdb", ); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert_eq!(url, "mysql://root:secret@localhost:3306/testdb"); } - #[test] - fn test_postgres_url_basic() { + #[tokio::test] + async fn test_postgres_url_basic() { let params = create_params( "postgres", "localhost", @@ -1186,19 +1099,19 @@ mod tests { Some("secret"), "testdb", ); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert_eq!(url, "postgres://postgres:secret@localhost:5432/testdb"); } - #[test] - fn test_sqlite_url() { + #[tokio::test] + async fn test_sqlite_url() { let params = create_params("sqlite", "", None, "", None, "/path/to/db.sqlite"); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert_eq!(url, "sqlite:///path/to/db.sqlite"); } - #[test] - fn test_url_encoding_special_chars() { + #[tokio::test] + async fn test_url_encoding_special_chars() { let params = create_params( "mysql", "localhost", @@ -1207,41 +1120,41 @@ mod tests { Some("pass#word"), "mydb", ); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert!(url.contains("user%40domain")); assert!(url.contains("pass%23word")); } - #[test] - fn test_default_ports() { + #[tokio::test] + async fn test_default_ports() { let mysql_params = create_params("mysql", "localhost", None, "root", None, "testdb"); let pg_params = create_params("postgres", "localhost", None, "postgres", None, "testdb"); - let mysql_url = build_connection_url(&mysql_params).unwrap(); - let pg_url = build_connection_url(&pg_params).unwrap(); + let mysql_url = build_connection_url(&mysql_params).await.unwrap(); + let pg_url = build_connection_url(&pg_params).await.unwrap(); assert!(mysql_url.contains(":3306/")); assert!(pg_url.contains(":5432/")); } - #[test] - fn test_no_password() { + #[tokio::test] + async fn test_no_password() { let params = create_params("mysql", "localhost", Some(3306), "root", None, "testdb"); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert_eq!(url, "mysql://root:@localhost:3306/testdb"); } - #[test] - fn test_unsupported_driver() { + #[tokio::test] + async fn test_unsupported_driver() { let params = create_params("mongodb", "localhost", Some(27017), "user", None, "testdb"); - let result = build_connection_url(¶ms); + let result = build_connection_url(¶ms).await; assert!(result.is_err()); assert_eq!(result.unwrap_err(), "Unsupported driver"); } - #[test] - fn test_remote_host() { + #[tokio::test] + async fn test_remote_host() { let params = create_params( "postgres", "db.example.com", @@ -1250,7 +1163,7 @@ mod tests { Some("pass"), "production", ); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert!(url.contains("db.example.com")); assert!(!url.contains("localhost")); } @@ -1439,16 +1352,16 @@ mod tests { } } - #[test] - fn test_non_ssh_params_unchanged() { + #[tokio::test] + async fn test_non_ssh_params_unchanged() { let params = base_params(); let result = resolve_connection_params(¶ms).unwrap(); assert_eq!(result.host, Some("localhost".to_string())); assert_eq!(result.port, Some(3306)); } - #[test] - fn test_ssh_params_require_host() { + #[tokio::test] + async fn test_ssh_params_require_host() { let mut params = create_ssh_params("jump.server", 22, "admin", "db.internal", 3306); params.ssh_host = None; let result = resolve_connection_params(¶ms); @@ -1456,8 +1369,8 @@ mod tests { assert!(result.unwrap_err().contains("SSH Host")); } - #[test] - fn test_ssh_params_require_user() { + #[tokio::test] + async fn test_ssh_params_require_user() { let mut params = create_ssh_params("jump.server", 22, "admin", "db.internal", 3306); params.ssh_user = None; let result = resolve_connection_params(¶ms); @@ -1469,54 +1382,54 @@ mod tests { mod url_encoding_edge_cases { use super::*; - #[test] - fn test_unicode_username() { + #[tokio::test] + async fn test_unicode_username() { let mut params = base_params(); params.username = Some("用户".to_string()); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); // URL should contain percent-encoded UTF-8 assert!(url.contains("%E7%94%A8%E6%88%B7")); } - #[test] - fn test_password_with_colon() { + #[tokio::test] + async fn test_password_with_colon() { let mut params = base_params(); params.password = Some("pass:word".to_string()); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert!(url.contains("pass%3Aword")); } - #[test] - fn test_password_with_at_sign() { + #[tokio::test] + async fn test_password_with_at_sign() { let mut params = base_params(); params.password = Some("pass@word".to_string()); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert!(url.contains("pass%40word")); } - #[test] - fn test_password_with_slash() { + #[tokio::test] + async fn test_password_with_slash() { let mut params = base_params(); params.password = Some("pass/word".to_string()); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert!(url.contains("pass%2Fword")); } - #[test] - fn test_empty_username_and_password() { + #[tokio::test] + async fn test_empty_username_and_password() { let mut params = base_params(); params.username = None; params.password = None; - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert!(url.contains(":@localhost")); } - #[test] - fn test_host_with_port_in_url() { + #[tokio::test] + async fn test_host_with_port_in_url() { let mut params = base_params(); params.host = Some("192.168.1.100".to_string()); params.port = Some(33060); - let url = build_connection_url(¶ms).unwrap(); + let url = build_connection_url(¶ms).await.unwrap(); assert!(url.contains("192.168.1.100:33060")); } } @@ -1554,23 +1467,8 @@ pub async fn list_databases( resolved_params.username, ); - match resolved_params.driver.as_str() { - "mysql" => { - let mut params = resolved_params.clone(); - params.database = "information_schema".to_string(); - // Clear connection_id so this temporary information_schema pool is not cached - // under the same key as the actual connection pool. - params.connection_id = None; - mysql::get_databases(¶ms).await - } - "postgres" => { - let mut params = resolved_params.clone(); - params.database = "postgres".to_string(); - postgres::get_databases(¶ms).await - } - "sqlite" => sqlite::get_databases(&resolved_params).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&resolved_params.driver).await?; + drv.get_databases(&resolved_params).await } #[tauri::command] @@ -1591,12 +1489,8 @@ pub async fn get_tables( params.database ); - let result = match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_tables(¶ms).await, - "postgres" => postgres::get_tables(¶ms, schema.as_deref().unwrap_or("public")).await, - "sqlite" => sqlite::get_tables(¶ms).await, - _ => Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.get_tables(¶ms, schema.as_deref()).await; match &result { Ok(tables) => log::info!("Retrieved {} tables from {}", tables.len(), params.database), @@ -1616,14 +1510,8 @@ pub async fn get_columns( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_columns(¶ms, &table_name).await, - "postgres" => { - postgres::get_columns(¶ms, &table_name, schema.as_deref().unwrap_or("public")).await - } - "sqlite" => sqlite::get_columns(¶ms, &table_name).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_columns(¶ms, &table_name, schema.as_deref()).await } #[tauri::command] @@ -1636,15 +1524,8 @@ pub async fn get_foreign_keys( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_foreign_keys(¶ms, &table_name).await, - "postgres" => { - postgres::get_foreign_keys(¶ms, &table_name, schema.as_deref().unwrap_or("public")) - .await - } - "sqlite" => sqlite::get_foreign_keys(¶ms, &table_name).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_foreign_keys(¶ms, &table_name, schema.as_deref()).await } #[tauri::command] @@ -1657,14 +1538,8 @@ pub async fn get_indexes( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_indexes(¶ms, &table_name).await, - "postgres" => { - postgres::get_indexes(¶ms, &table_name, schema.as_deref().unwrap_or("public")).await - } - "sqlite" => sqlite::get_indexes(¶ms, &table_name).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_indexes(¶ms, &table_name, schema.as_deref()).await } #[tauri::command] @@ -1679,21 +1554,8 @@ pub async fn delete_record( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => mysql::delete_record(¶ms, &table, &pk_col, pk_val).await, - "postgres" => { - postgres::delete_record( - ¶ms, - &table, - &pk_col, - pk_val, - schema.as_deref().unwrap_or("public"), - ) - .await - } - "sqlite" => sqlite::delete_record(¶ms, &table, &pk_col, pk_val).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.delete_record(¶ms, &table, &pk_col, pk_val, schema.as_deref()).await } #[tauri::command] @@ -1711,46 +1573,8 @@ pub async fn update_record( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; let max_blob_size = crate::config::get_max_blob_size(&app); - match saved_conn.params.driver.as_str() { - "mysql" => { - mysql::update_record( - ¶ms, - &table, - &pk_col, - pk_val, - &col_name, - new_val, - max_blob_size, - ) - .await - } - "postgres" => { - postgres::update_record( - ¶ms, - &table, - &pk_col, - pk_val, - &col_name, - new_val, - schema.as_deref().unwrap_or("public"), - max_blob_size, - ) - .await - } - "sqlite" => { - sqlite::update_record( - ¶ms, - &table, - &pk_col, - pk_val, - &col_name, - new_val, - max_blob_size, - ) - .await - } - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.update_record(¶ms, &table, &pk_col, pk_val, &col_name, new_val, schema.as_deref(), max_blob_size).await } #[tauri::command] @@ -1767,31 +1591,8 @@ pub async fn save_blob_to_file( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - match saved_conn.params.driver.as_str() { - "mysql" => { - mysql::save_blob_column_to_file(¶ms, &table, &col_name, &pk_col, pk_val, &file_path) - .await - } - "postgres" => { - postgres::save_blob_column_to_file( - ¶ms, - &table, - &col_name, - &pk_col, - pk_val, - schema.as_deref().unwrap_or("public"), - &file_path, - ) - .await - } - "sqlite" => { - sqlite::save_blob_column_to_file( - ¶ms, &table, &col_name, &pk_col, pk_val, &file_path, - ) - .await - } - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.save_blob_to_file(¶ms, &table, &col_name, &pk_col, pk_val, schema.as_deref(), &file_path).await } /// Fetches a BLOB column from the database and returns it as a data: URL for image preview. @@ -1809,28 +1610,8 @@ pub async fn fetch_blob_as_data_url( let saved_conn = find_connection_by_id(&app, &connection_id)?; let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - let wire = match saved_conn.params.driver.as_str() { - "mysql" => { - mysql::fetch_blob_column_as_data_url(¶ms, &table, &col_name, &pk_col, pk_val) - .await? - } - "postgres" => { - postgres::fetch_blob_column_as_data_url( - ¶ms, - &table, - &col_name, - &pk_col, - pk_val, - schema.as_deref().unwrap_or("public"), - ) - .await? - } - "sqlite" => { - sqlite::fetch_blob_column_as_data_url(¶ms, &table, &col_name, &pk_col, pk_val) - .await? - } - _ => return Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let wire = drv.fetch_blob_as_data_url(¶ms, &table, &col_name, &pk_col, pk_val, schema.as_deref()).await?; // Convert the BLOB wire format to a data: URL // wire format: "BLOB:::" if !wire.starts_with("BLOB:") { @@ -2008,21 +1789,8 @@ pub async fn insert_record( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; let max_blob_size = crate::config::get_max_blob_size(&app); - match saved_conn.params.driver.as_str() { - "mysql" => mysql::insert_record(¶ms, &table, data, max_blob_size).await, - "postgres" => { - postgres::insert_record( - ¶ms, - &table, - data, - schema.as_deref().unwrap_or("public"), - max_blob_size, - ) - .await - } - "sqlite" => sqlite::insert_record(¶ms, &table, data, max_blob_size).await, - _ => Err("Unsupported driver".into()), - } + let drv = driver_for(&saved_conn.params.driver).await?; + drv.insert_record(¶ms, &table, data, schema.as_deref(), max_blob_size).await } #[tauri::command] @@ -2070,26 +1838,9 @@ pub async fn execute_query( let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; // 2. Spawn Cancellable Task + let drv = driver_for(&saved_conn.params.driver).await?; let task = tokio::spawn(async move { - match saved_conn.params.driver.as_str() { - "mysql" => { - mysql::execute_query(¶ms, &sanitized_query, limit, page.unwrap_or(1)).await - } - "postgres" => { - postgres::execute_query( - ¶ms, - &sanitized_query, - limit, - page.unwrap_or(1), - schema.as_deref(), - ) - .await - } - "sqlite" => { - sqlite::execute_query(¶ms, &sanitized_query, limit, page.unwrap_or(1)).await - } - _ => Err("Unsupported driver".into()), - } + drv.execute_query(¶ms, &sanitized_query, limit, page.unwrap_or(1), schema.as_deref()).await }); // 3. Register Abort Handle @@ -2228,9 +1979,7 @@ pub async fn open_er_diagram_window( } /// Builds a connection URL for a database driver. -/// This is a pure function that can be tested without a database connection. -#[inline] -pub fn build_connection_url(params: &ConnectionParams) -> Result { +pub async fn build_connection_url(params: &ConnectionParams) -> Result { let user = encode(params.username.as_deref().unwrap_or_default()); let pass = encode(params.password.as_deref().unwrap_or_default()); let host = params.host.as_deref().unwrap_or("localhost"); @@ -2358,12 +2107,8 @@ pub async fn get_views( params.database ); - let result = match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_views(¶ms).await, - "postgres" => postgres::get_views(¶ms, schema.as_deref().unwrap_or("public")).await, - "sqlite" => sqlite::get_views(¶ms).await, - _ => Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.get_views(¶ms, schema.as_deref()).await; match &result { Ok(views) => log::info!("Retrieved {} views from {}", views.len(), params.database), @@ -2390,19 +2135,8 @@ pub async fn get_view_definition( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - let result = match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_view_definition(¶ms, &view_name).await, - "postgres" => { - postgres::get_view_definition( - ¶ms, - &view_name, - schema.as_deref().unwrap_or("public"), - ) - .await - } - "sqlite" => sqlite::get_view_definition(¶ms, &view_name).await, - _ => Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.get_view_definition(¶ms, &view_name, schema.as_deref()).await; match &result { Ok(_) => log::info!("Successfully retrieved view definition for {}", view_name), @@ -2430,20 +2164,8 @@ pub async fn create_view( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - let result = match saved_conn.params.driver.as_str() { - "mysql" => mysql::create_view(¶ms, &view_name, &definition).await, - "postgres" => { - postgres::create_view( - ¶ms, - &view_name, - &definition, - schema.as_deref().unwrap_or("public"), - ) - .await - } - "sqlite" => sqlite::create_view(¶ms, &view_name, &definition).await, - _ => Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.create_view(¶ms, &view_name, &definition, schema.as_deref()).await; match &result { Ok(_) => log::info!("Successfully created view: {}", view_name), @@ -2471,20 +2193,8 @@ pub async fn alter_view( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - let result = match saved_conn.params.driver.as_str() { - "mysql" => mysql::alter_view(¶ms, &view_name, &definition).await, - "postgres" => { - postgres::alter_view( - ¶ms, - &view_name, - &definition, - schema.as_deref().unwrap_or("public"), - ) - .await - } - "sqlite" => sqlite::alter_view(¶ms, &view_name, &definition).await, - _ => Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.alter_view(¶ms, &view_name, &definition, schema.as_deref()).await; match &result { Ok(_) => log::info!("Successfully altered view: {}", view_name), @@ -2511,14 +2221,8 @@ pub async fn drop_view( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - let result = match saved_conn.params.driver.as_str() { - "mysql" => mysql::drop_view(¶ms, &view_name).await, - "postgres" => { - postgres::drop_view(¶ms, &view_name, schema.as_deref().unwrap_or("public")).await - } - "sqlite" => sqlite::drop_view(¶ms, &view_name).await, - _ => Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.drop_view(¶ms, &view_name, schema.as_deref()).await; match &result { Ok(_) => log::info!("Successfully dropped view: {}", view_name), @@ -2545,15 +2249,8 @@ pub async fn get_view_columns( let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; - let result = match saved_conn.params.driver.as_str() { - "mysql" => mysql::get_view_columns(¶ms, &view_name).await, - "postgres" => { - postgres::get_view_columns(¶ms, &view_name, schema.as_deref().unwrap_or("public")) - .await - } - "sqlite" => sqlite::get_view_columns(¶ms, &view_name).await, - _ => Err("Unsupported driver".into()), - }; + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.get_view_columns(¶ms, &view_name, schema.as_deref()).await; match &result { Ok(columns) => log::info!("Retrieved {} columns for view {}", columns.len(), view_name), @@ -2588,18 +2285,125 @@ pub async fn disconnect_connection( // --- Type Registry --- #[tauri::command] -pub fn get_data_types(driver: String) -> crate::models::DataTypeRegistry { +pub async fn get_data_types(driver: String) -> Result { log::debug!("Fetching data types for driver: {}", driver); - let types = match driver.to_lowercase().as_str() { - "postgres" => postgres::types::get_data_types(), - "mysql" | "mariadb" => mysql::types::get_data_types(), - "sqlite" => sqlite::types::get_data_types(), - _ => { - log::warn!("Unknown driver: {}, returning empty type list", driver); - vec![] - } - }; + let drv = driver_for(&driver).await?; + let types = drv.get_data_types(); + + Ok(crate::models::DataTypeRegistry { driver, types }) +} + + +// --- DDL generation commands --- + +#[tauri::command] +pub async fn get_create_table_sql( + app: AppHandle, + connection_id: String, + table_name: String, + columns: Vec, + schema: Option, +) -> Result, String> { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_create_table_sql(&table_name, columns, schema.as_deref()).await +} + +#[tauri::command] +pub async fn get_add_column_sql( + app: AppHandle, + connection_id: String, + table: String, + column: ColumnDefinition, + schema: Option, +) -> Result, String> { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_add_column_sql(&table, column, schema.as_deref()).await +} - crate::models::DataTypeRegistry { driver, types } +#[tauri::command] +pub async fn get_alter_column_sql( + app: AppHandle, + connection_id: String, + table: String, + old_column: ColumnDefinition, + new_column: ColumnDefinition, + schema: Option, +) -> Result, String> { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_alter_column_sql(&table, old_column, new_column, schema.as_deref()).await +} + +#[tauri::command] +pub async fn get_create_index_sql( + app: AppHandle, + connection_id: String, + table: String, + index_name: String, + columns: Vec, + is_unique: bool, + schema: Option, +) -> Result, String> { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_create_index_sql(&table, &index_name, columns, is_unique, schema.as_deref()).await +} + +#[tauri::command] +pub async fn get_create_foreign_key_sql( + app: AppHandle, + connection_id: String, + table: String, + fk_name: String, + column: String, + ref_table: String, + ref_column: String, + on_delete: Option, + on_update: Option, + schema: Option, +) -> Result, String> { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.get_create_foreign_key_sql( + &table, &fk_name, &column, &ref_table, &ref_column, + on_delete.as_deref(), on_update.as_deref(), schema.as_deref(), + ).await +} + +#[tauri::command] +pub async fn drop_index_action( + app: AppHandle, + connection_id: String, + table: String, + index_name: String, + schema: Option, +) -> Result<(), String> { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.drop_index(¶ms, &table, &index_name, schema.as_deref()).await +} + +#[tauri::command] +pub async fn drop_foreign_key_action( + app: AppHandle, + connection_id: String, + table: String, + fk_name: String, + schema: Option, +) -> Result<(), String> { + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + let drv = driver_for(&saved_conn.params.driver).await?; + drv.drop_foreign_key(¶ms, &table, &fk_name, schema.as_deref()).await +} + +#[tauri::command] +pub async fn get_registered_drivers() -> Vec { + crate::drivers::registry::list_drivers().await } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 01049bf..0a4c3c0 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -29,6 +29,7 @@ pub struct AppConfig { pub schema_preferences: Option>, pub selected_schemas: Option>>, pub max_blob_size: Option, + pub active_external_drivers: Option>, } pub fn get_config_dir(app: &AppHandle) -> Option { @@ -124,6 +125,9 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { if config.max_blob_size.is_some() { existing_config.max_blob_size = config.max_blob_size; } + if config.active_external_drivers.is_some() { + existing_config.active_external_drivers = config.active_external_drivers; + } let content = serde_json::to_string_pretty(&existing_config).map_err(|e| e.to_string())?; fs::write(config_path, content).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/drivers/PLUGIN_GUIDE.md b/src-tauri/src/drivers/PLUGIN_GUIDE.md new file mode 100644 index 0000000..21ed532 --- /dev/null +++ b/src-tauri/src/drivers/PLUGIN_GUIDE.md @@ -0,0 +1,225 @@ +# Writing a Custom Database Driver Plugin for Tabularis + +Tabularis supports extending its capabilities via a JSON-RPC based external plugin system. By building a standalone executable that implements the JSON-RPC interface, you can add support for virtually any SQL or NoSQL database (such as DuckDB, MongoDB, etc.) using the programming language of your choice. + +This guide details how to implement and register a custom external plugin. + +## 1. Plugin Architecture + +An external plugin in Tabularis is a separate executable (binary or script) that runs alongside the main application. Tabularis communicates with the plugin using **JSON-RPC 2.0** over standard input/output (`stdin` / `stdout`). + +- **Requests:** Tabularis writes JSON-RPC request objects to the plugin's `stdin`, separated by a newline (`\n`). +- **Responses:** The plugin processes the request and writes a JSON-RPC response object to its `stdout`, followed by a newline (`\n`). +- **Logging:** Any output to `stderr` from the plugin is inherited or logged by Tabularis without interfering with the JSON-RPC communication. + +### Lifecycle +1. Tabularis discovers plugins in its configuration folder (`~/.config/tabularis/plugins/` on Linux, `%APPDATA%\tabularis\plugins\` on Windows, `~/Library/Application Support/com.debba.tabularis/plugins/` on macOS). +2. It reads the `manifest.json` for each plugin to discover its capabilities. +3. When the user interacts with the database, Tabularis spawns the plugin executable and sends RPC messages. + +--- + +## 2. Directory Structure & `manifest.json` + +A Tabularis plugin is distributed as a `.zip` file containing a specific directory structure. When extracted into the `plugins` folder, it must look like this: + +```text +plugins/ +└── duckdb-plugin/ + ├── manifest.json + └── duckdb-plugin-executable (or .exe / script) +``` + +### The `manifest.json` + +The manifest tells Tabularis about your plugin, including which executable to launch. + +```json +{ + "id": "duckdb", + "name": "DuckDB", + "version": "1.0.0", + "description": "DuckDB file-based analytical database", + "default_port": null, + "executable": "duckdb-plugin-executable", + "capabilities": { + "schemas": false, + "views": true, + "routines": false, + "file_based": true + }, + "data_types": [ + { + "name": "INTEGER", + "category": "numeric", + "has_length": false + }, + { + "name": "VARCHAR", + "category": "string", + "has_length": true + } + ] +} +``` + +- `id`: Unique identifier for the driver (e.g., `duckdb`). +- `executable`: The relative path to the executable file inside the plugin folder. +- `capabilities`: + - `schemas`: Set to `true` if the database uses schemas (like PostgreSQL). + - `file_based`: Set to `true` if it's a local file database (like SQLite or DuckDB) requiring no host/port. + +--- + +## 3. Implementing the JSON-RPC Interface + +Your plugin must continuously read from `stdin`, parse the JSON-RPC request, execute the requested database operation, and write the response to `stdout`. + +### JSON-RPC Communication Example + +**Request from Tabularis:** + +```json +{ + "jsonrpc": "2.0", + "method": "get_tables", + "params": { + "params": { + "driver": "duckdb", + "database": "/path/to/my_database.duckdb" + }, + "schema": null + }, + "id": 1 +} +``` + +**Successful Response from Plugin:** + +```json +{ + "jsonrpc": "2.0", + "result": [ + { + "name": "users", + "schema": "main", + "comment": null + } + ], + "id": 1 +} +``` + +**Error Response from Plugin:** + +```json +{ + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": "Database file not found or inaccessible." + }, + "id": 1 +} +``` + +--- + +## 4. Required Methods + +Your plugin must respond to the following JSON-RPC methods (matching the `DatabaseDriver` trait in Tabularis): + +- `get_databases` +- `get_schemas` +- `get_tables` +- `get_columns` +- `get_foreign_keys` +- `get_indexes` +- `get_views` +- `get_view_definition` +- `get_view_columns` +- `create_view` +- `alter_view` +- `drop_view` +- `get_routines` +- `get_routine_parameters` +- `get_routine_definition` +- `execute_query` +- `insert_record` +- `update_record` +- `delete_record` +- `get_schema_snapshot` +- `get_all_columns_batch` +- `get_all_foreign_keys_batch` + +> **Note:** If your database doesn't support a feature (e.g., routines or views), you can return an empty array `[]` or a standard JSON-RPC error. + +--- + +## 5. Example: Building a Minimal Plugin in Rust + +Here is a minimal example of how a plugin executable might look in Rust. + +```rust +use std::io::{self, BufRead, Write}; +use serde_json::{json, Value}; + +fn main() { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + + for line in stdin.lock().lines() { + let line = line.unwrap(); + if line.trim().is_empty() { + continue; + } + + // Parse Request + let req: Value = serde_json::from_str(&line).unwrap(); + let id = req["id"].clone(); + let method = req["method"].as_str().unwrap(); + + // Process Method + let response = match method { + "get_tables" => { + json!({ + "jsonrpc": "2.0", + "result": [ + { "name": "mock_table", "schema": "public", "comment": null } + ], + "id": id + }) + }, + _ => { + json!({ + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": format!("Method '{}' not implemented", method) + }, + "id": id + }) + } + }; + + // Send Response + let mut res_str = serde_json::to_string(&response).unwrap(); + res_str.push('\n'); + stdout.write_all(res_str.as_bytes()).unwrap(); + stdout.flush().unwrap(); + } +} +``` + +--- + +## 6. Testing Your Plugin + +To test your plugin during development: +1. Manually create the directory structure in Tabularis's config plugins folder. + - Linux: `~/.local/share/tabularis/plugins/mydriver/` (or `~/.config/tabularis/plugins/`) + - macOS: `~/Library/Application Support/com.debba.tabularis/plugins/mydriver/` + - Windows: `C:\Users\\AppData\Roaming\com.debba.tabularis\plugins\mydriver\` +2. Place your `manifest.json` and the executable there. +3. Restart Tabularis. +4. Go to **Settings > Plugins** (if implemented in UI) or try creating a new Connection; your driver should appear in the "Database Type" list. diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs new file mode 100644 index 0000000..091fa07 --- /dev/null +++ b/src-tauri/src/drivers/driver_trait.rs @@ -0,0 +1,346 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use sqlx::any::AnyConnectOptions; +use sqlx::{AnyConnection, Connection}; +use std::str::FromStr; + +use crate::models::{ + ColumnDefinition, ConnectionParams, DataTypeInfo, ForeignKey, Index, QueryResult, RoutineInfo, + RoutineParameter, TableColumn, TableInfo, TableSchema, ViewInfo, +}; + +/// Capabilities advertised by a driver. +/// The frontend uses these flags to decide which UI sections to show. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DriverCapabilities { + /// Supports multiple named schemas (e.g. PostgreSQL). + pub schemas: bool, + /// Supports views. + pub views: bool, + /// Supports stored procedures and functions. + pub routines: bool, + /// File-based database (e.g. SQLite); no host/port required. + pub file_based: bool, + /// Character used to quote identifiers (e.g. `"` for PostgreSQL, `` ` `` for MySQL). + #[serde(default = "default_double_quote")] + pub identifier_quote: String, +} + +fn default_double_quote() -> String { + "\"".to_string() +} + +/// Metadata describing a registered driver plugin. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PluginManifest { + /// Unique identifier used in `ConnectionParams.driver` (e.g. `"mysql"`). + pub id: String, + /// Human-readable name shown in the UI (e.g. `"MySQL"`). + pub name: String, + /// Semver string of this driver implementation (e.g. `"1.0.0"`). + pub version: String, + /// Short description shown in the UI. + pub description: String, + /// Default TCP port, `None` for file-based drivers. + pub default_port: Option, + pub capabilities: DriverCapabilities, +} + +/// The complete interface every database driver plugin must implement. +/// +/// The `schema` parameter is `Option<&str>` throughout. Drivers that do not +/// use schemas (MySQL, SQLite) simply ignore it. Drivers that do (PostgreSQL) +/// fall back to `"public"` when it is `None`. +#[async_trait] +pub trait DatabaseDriver: Send + Sync { + // --- Metadata ----------------------------------------------------------- + + fn manifest(&self) -> &PluginManifest; + + /// Returns the list of data types supported by this driver. + fn get_data_types(&self) -> Vec; + + /// Builds the connection URL string for this driver. + fn build_connection_url(&self, params: &ConnectionParams) -> Result; + + /// Tests connectivity. Default implementation uses `build_connection_url` + sqlx. + /// Plugin drivers that manage their own connections should override this. + async fn test_connection(&self, params: &ConnectionParams) -> Result<(), String> { + let url = self.build_connection_url(params)?; + let options = AnyConnectOptions::from_str(&url).map_err(|e| e.to_string())?; + let mut conn: AnyConnection = AnyConnection::connect_with(&options) + .await + .map_err(|e: sqlx::Error| e.to_string())?; + conn.ping().await.map_err(|e: sqlx::Error| e.to_string())?; + Ok(()) + } + + // --- Database / schema discovery ---------------------------------------- + + async fn get_databases(&self, params: &ConnectionParams) -> Result, String>; + async fn get_schemas(&self, params: &ConnectionParams) -> Result, String>; + + // --- Schema inspection --------------------------------------------------- + + async fn get_tables( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_columns( + &self, + params: &ConnectionParams, + table: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_foreign_keys( + &self, + params: &ConnectionParams, + table: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_indexes( + &self, + params: &ConnectionParams, + table: &str, + schema: Option<&str>, + ) -> Result, String>; + + // --- Views -------------------------------------------------------------- + + async fn get_views( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_view_definition( + &self, + params: &ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result; + + async fn get_view_columns( + &self, + params: &ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn create_view( + &self, + params: &ConnectionParams, + view_name: &str, + definition: &str, + schema: Option<&str>, + ) -> Result<(), String>; + + async fn alter_view( + &self, + params: &ConnectionParams, + view_name: &str, + definition: &str, + schema: Option<&str>, + ) -> Result<(), String>; + + async fn drop_view( + &self, + params: &ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result<(), String>; + + // --- Routines ----------------------------------------------------------- + + async fn get_routines( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_routine_parameters( + &self, + params: &ConnectionParams, + routine_name: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_routine_definition( + &self, + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result; + + // --- Query execution ---------------------------------------------------- + + async fn execute_query( + &self, + params: &ConnectionParams, + query: &str, + limit: Option, + page: u32, + schema: Option<&str>, + ) -> Result; + + // --- CRUD --------------------------------------------------------------- + + async fn insert_record( + &self, + params: &ConnectionParams, + table: &str, + data: HashMap, + schema: Option<&str>, + max_blob_size: u64, + ) -> Result; + + async fn update_record( + &self, + params: &ConnectionParams, + table: &str, + pk_col: &str, + pk_val: serde_json::Value, + col_name: &str, + new_val: serde_json::Value, + schema: Option<&str>, + max_blob_size: u64, + ) -> Result; + + async fn delete_record( + &self, + params: &ConnectionParams, + table: &str, + pk_col: &str, + pk_val: serde_json::Value, + schema: Option<&str>, + ) -> Result; + + // --- BLOB helpers (optional, built-in drivers only) --------------------- + + async fn save_blob_to_file( + &self, + _params: &ConnectionParams, + _table: &str, + _col_name: &str, + _pk_col: &str, + _pk_val: serde_json::Value, + _schema: Option<&str>, + _file_path: &str, + ) -> Result<(), String> { + Err("BLOB file export not supported by this driver".into()) + } + + async fn fetch_blob_as_data_url( + &self, + _params: &ConnectionParams, + _table: &str, + _col_name: &str, + _pk_col: &str, + _pk_val: serde_json::Value, + _schema: Option<&str>, + ) -> Result { + Err("BLOB preview not supported by this driver".into()) + } + + // --- DDL generation (SQL preview) ---------------------------------------- + + async fn get_create_table_sql( + &self, + _table_name: &str, + _columns: Vec, + _schema: Option<&str>, + ) -> Result, String> { + Err("DDL generation not supported".into()) + } + + async fn get_add_column_sql( + &self, + _table: &str, + _column: ColumnDefinition, + _schema: Option<&str>, + ) -> Result, String> { + Err("DDL generation not supported".into()) + } + + async fn get_alter_column_sql( + &self, + _table: &str, + _old_column: ColumnDefinition, + _new_column: ColumnDefinition, + _schema: Option<&str>, + ) -> Result, String> { + Err("DDL generation not supported".into()) + } + + async fn get_create_index_sql( + &self, + _table: &str, + _index_name: &str, + _columns: Vec, + _is_unique: bool, + _schema: Option<&str>, + ) -> Result, String> { + Err("DDL generation not supported".into()) + } + + async fn get_create_foreign_key_sql( + &self, + _table: &str, + _fk_name: &str, + _column: &str, + _ref_table: &str, + _ref_column: &str, + _on_delete: Option<&str>, + _on_update: Option<&str>, + _schema: Option<&str>, + ) -> Result, String> { + Err("DDL generation not supported".into()) + } + + async fn drop_index( + &self, + _params: &ConnectionParams, + _table: &str, + _index_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + Err("Not supported".into()) + } + + async fn drop_foreign_key( + &self, + _params: &ConnectionParams, + _table: &str, + _fk_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + Err("Not supported".into()) + } + + // --- ER diagram (batch) ------------------------------------------------- + + async fn get_schema_snapshot( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_all_columns_batch( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result>, String>; + + async fn get_all_foreign_keys_batch( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result>, String>; +} diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 2871e7e..b466864 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -969,3 +969,348 @@ pub async fn execute_query( pagination, }) } + +// ============================================================ +// Plugin wrapper +// ============================================================ + +use crate::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct MysqlDriver { + manifest: PluginManifest, +} + +impl MysqlDriver { + pub fn new() -> Self { + Self { + manifest: PluginManifest { + id: "mysql".to_string(), + name: "MySQL".to_string(), + version: "1.0.0".to_string(), + description: "MySQL and MariaDB databases".to_string(), + default_port: Some(3306), + capabilities: DriverCapabilities { + schemas: false, + views: true, + routines: true, + file_based: false, + identifier_quote: "`".into(), + }, + }, + } + } +} + +#[async_trait] +impl DatabaseDriver for MysqlDriver { + fn manifest(&self) -> &PluginManifest { &self.manifest } + + fn get_data_types(&self) -> Vec { + types::get_data_types() + } + + fn build_connection_url(&self, params: &crate::models::ConnectionParams) -> Result { + use urlencoding::encode; + let user = encode(params.username.as_deref().unwrap_or_default()); + let pass = encode(params.password.as_deref().unwrap_or_default()); + Ok(format!( + "mysql://{}:{}@{}:{}/{}", + user, pass, + params.host.as_deref().unwrap_or("localhost"), + params.port.unwrap_or(3306), + params.database + )) + } + + async fn get_databases(&self, params: &crate::models::ConnectionParams) -> Result, String> { + // MySQL requires connecting to information_schema to list databases + let mut p = params.clone(); + p.database = "information_schema".to_string(); + p.connection_id = None; // avoid caching under the real connection key + get_databases(&p).await + } + + async fn get_schemas(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_schemas(params).await + } + + async fn get_tables(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_tables(params).await + } + + async fn get_columns(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_columns(params, table).await + } + + async fn get_foreign_keys(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_foreign_keys(params, table).await + } + + async fn get_indexes(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_indexes(params, table).await + } + + async fn get_views(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_views(params).await + } + + async fn get_view_definition(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result { + get_view_definition(params, view_name).await + } + + async fn get_view_columns(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result, String> { + get_view_columns(params, view_name).await + } + + async fn create_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + create_view(params, view_name, definition).await + } + + async fn alter_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + alter_view(params, view_name, definition).await + } + + async fn drop_view(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result<(), String> { + drop_view(params, view_name).await + } + + async fn get_routines(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_routines(params).await + } + + async fn get_routine_parameters(&self, params: &crate::models::ConnectionParams, routine_name: &str, _schema: Option<&str>) -> Result, String> { + get_routine_parameters(params, routine_name).await + } + + async fn get_routine_definition(&self, params: &crate::models::ConnectionParams, routine_name: &str, routine_type: &str, _schema: Option<&str>) -> Result { + get_routine_definition(params, routine_name, routine_type).await + } + + async fn execute_query(&self, params: &crate::models::ConnectionParams, query: &str, limit: Option, page: u32, _schema: Option<&str>) -> Result { + execute_query(params, query, limit, page).await + } + + async fn insert_record(&self, params: &crate::models::ConnectionParams, table: &str, data: std::collections::HashMap, _schema: Option<&str>, max_blob_size: u64) -> Result { + insert_record(params, table, data, max_blob_size).await + } + + async fn update_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, _schema: Option<&str>, max_blob_size: u64) -> Result { + update_record(params, table, pk_col, pk_val, col_name, new_val, max_blob_size).await + } + + async fn delete_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>) -> Result { + delete_record(params, table, pk_col, pk_val).await + } + + async fn save_blob_to_file(&self, params: &crate::models::ConnectionParams, table: &str, col_name: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>, file_path: &str) -> Result<(), String> { + save_blob_column_to_file(params, table, col_name, pk_col, pk_val, file_path).await + } + + async fn fetch_blob_as_data_url(&self, params: &crate::models::ConnectionParams, table: &str, col_name: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>) -> Result { + fetch_blob_column_as_data_url(params, table, col_name, pk_col, pk_val).await + } + + async fn get_create_table_sql( + &self, + table_name: &str, + columns: Vec, + _schema: Option<&str>, + ) -> Result, String> { + let mut col_defs = Vec::new(); + let mut pk_cols = Vec::new(); + for col in &columns { + let mut def = format!("`{}` {}", escape_identifier(&col.name), col.data_type); + if !col.is_nullable { + def.push_str(" NOT NULL"); + } + if col.is_auto_increment { + def.push_str(" AUTO_INCREMENT"); + } + if let Some(default) = &col.default_value { + def.push_str(&format!(" DEFAULT {}", default)); + } + col_defs.push(def); + if col.is_pk { + pk_cols.push(format!("`{}`", escape_identifier(&col.name))); + } + } + if !pk_cols.is_empty() { + col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); + } + Ok(vec![format!( + "CREATE TABLE `{}` (\n {}\n)", + escape_identifier(table_name), + col_defs.join(",\n ") + )]) + } + + async fn get_add_column_sql( + &self, + table: &str, + column: crate::models::ColumnDefinition, + _schema: Option<&str>, + ) -> Result, String> { + let mut def = format!( + "ALTER TABLE `{}` ADD COLUMN `{}` {}", + escape_identifier(table), + escape_identifier(&column.name), + column.data_type + ); + if !column.is_nullable { + def.push_str(" NOT NULL"); + } else { + def.push_str(" NULL"); + } + if column.is_auto_increment { + def.push_str(" AUTO_INCREMENT"); + } + if let Some(default) = &column.default_value { + def.push_str(&format!(" DEFAULT {}", default)); + } + if column.is_pk { + def.push_str(" PRIMARY KEY"); + } + Ok(vec![def]) + } + + async fn get_alter_column_sql( + &self, + table: &str, + old_column: crate::models::ColumnDefinition, + new_column: crate::models::ColumnDefinition, + _schema: Option<&str>, + ) -> Result, String> { + let mut def = String::new(); + if old_column.name != new_column.name { + def.push_str(&format!( + "ALTER TABLE `{}` CHANGE `{}` `{}` {}", + escape_identifier(table), + escape_identifier(&old_column.name), + escape_identifier(&new_column.name), + new_column.data_type + )); + } else { + def.push_str(&format!( + "ALTER TABLE `{}` MODIFY COLUMN `{}` {}", + escape_identifier(table), + escape_identifier(&new_column.name), + new_column.data_type + )); + } + if !new_column.is_nullable { + def.push_str(" NOT NULL"); + } else { + def.push_str(" NULL"); + } + if new_column.is_auto_increment { + def.push_str(" AUTO_INCREMENT"); + } + if let Some(default) = &new_column.default_value { + def.push_str(&format!(" DEFAULT {}", default)); + } + Ok(vec![def]) + } + + async fn get_create_index_sql( + &self, + table: &str, + index_name: &str, + columns: Vec, + is_unique: bool, + _schema: Option<&str>, + ) -> Result, String> { + let unique = if is_unique { "UNIQUE " } else { "" }; + let cols: Vec = columns + .iter() + .map(|c| format!("`{}`", escape_identifier(c))) + .collect(); + Ok(vec![format!( + "CREATE {}INDEX `{}` ON `{}` ({})", + unique, + escape_identifier(index_name), + escape_identifier(table), + cols.join(", ") + )]) + } + + async fn get_create_foreign_key_sql( + &self, + table: &str, + fk_name: &str, + column: &str, + ref_table: &str, + ref_column: &str, + on_delete: Option<&str>, + on_update: Option<&str>, + _schema: Option<&str>, + ) -> Result, String> { + let mut sql = format!( + "ALTER TABLE `{}` ADD CONSTRAINT `{}` FOREIGN KEY (`{}`) REFERENCES `{}` (`{}`)", + escape_identifier(table), + escape_identifier(fk_name), + escape_identifier(column), + escape_identifier(ref_table), + escape_identifier(ref_column) + ); + if let Some(action) = on_delete { + sql.push_str(&format!(" ON DELETE {}", action)); + } + if let Some(action) = on_update { + sql.push_str(&format!(" ON UPDATE {}", action)); + } + Ok(vec![sql]) + } + + async fn drop_index( + &self, + params: &crate::models::ConnectionParams, + table: &str, + index_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + let sql = format!( + "DROP INDEX `{}` ON `{}`", + escape_identifier(index_name), + escape_identifier(table) + ); + execute_query(params, &sql, None, 1).await?; + Ok(()) + } + + async fn drop_foreign_key( + &self, + params: &crate::models::ConnectionParams, + table: &str, + fk_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + let sql = format!( + "ALTER TABLE `{}` DROP FOREIGN KEY `{}`", + escape_identifier(table), + escape_identifier(fk_name) + ); + execute_query(params, &sql, None, 1).await?; + Ok(()) + } + + async fn get_all_columns_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + get_all_columns_batch(params).await + } + + async fn get_all_foreign_keys_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + get_all_foreign_keys_batch(params).await + } + + async fn get_schema_snapshot(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + let tables = self.get_tables(params, schema).await?; + let mut columns_map = self.get_all_columns_batch(params, schema).await?; + let mut fks_map = self.get_all_foreign_keys_batch(params, schema).await?; + Ok(tables.into_iter().map(|t| crate::models::TableSchema { + name: t.name.clone(), + columns: columns_map.remove(&t.name).unwrap_or_default(), + foreign_keys: fks_map.remove(&t.name).unwrap_or_default(), + }).collect()) + } +} diff --git a/src-tauri/src/drivers/plugin-ecosystem-implementation.md b/src-tauri/src/drivers/plugin-ecosystem-implementation.md new file mode 100644 index 0000000..0dd7d09 --- /dev/null +++ b/src-tauri/src/drivers/plugin-ecosystem-implementation.md @@ -0,0 +1,1137 @@ +# Plugin Ecosystem — Full Implementation + +## Overview + +This document contains the complete code for every file that needs to be created or modified +to transform the current hard-coded driver dispatch into an open plugin registry. + +**Dependencies already present in `Cargo.toml`:** +- `async-trait = "0.1"` ✓ +- `once_cell = "1.20"` ✓ +- `tokio` with `full` features ✓ + +--- + +## 1. `src-tauri/src/drivers/driver_trait.rs` — New file + +```rust +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::models::{ + ConnectionParams, DataTypeInfo, ForeignKey, Index, QueryResult, RoutineInfo, + RoutineParameter, TableColumn, TableInfo, TableSchema, ViewInfo, +}; + +/// Capabilities advertised by a driver. +/// The frontend uses these flags to decide which UI sections to show. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DriverCapabilities { + /// Supports multiple named schemas (e.g. PostgreSQL). + pub schemas: bool, + /// Supports views. + pub views: bool, + /// Supports stored procedures and functions. + pub routines: bool, + /// File-based database (e.g. SQLite); no host/port required. + pub file_based: bool, +} + +/// Metadata describing a registered driver plugin. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PluginManifest { + /// Unique identifier used in `ConnectionParams.driver` (e.g. `"mysql"`). + pub id: String, + /// Human-readable name shown in the UI (e.g. `"MySQL"`). + pub name: String, + /// Semver string of this driver implementation (e.g. `"1.0.0"`). + pub version: String, + /// Short description shown in the UI. + pub description: String, + /// Default TCP port, `None` for file-based drivers. + pub default_port: Option, + pub capabilities: DriverCapabilities, +} + +/// The complete interface every database driver plugin must implement. +/// +/// The `schema` parameter is `Option<&str>` throughout. Drivers that do not +/// use schemas (MySQL, SQLite) simply ignore it. Drivers that do (PostgreSQL) +/// fall back to `"public"` when it is `None`. +#[async_trait] +pub trait DatabaseDriver: Send + Sync { + // --- Metadata ----------------------------------------------------------- + + fn manifest(&self) -> &PluginManifest; + + /// Returns the list of data types supported by this driver. + fn get_data_types(&self) -> Vec; + + /// Builds the connection URL string for this driver. + fn build_connection_url(&self, params: &ConnectionParams) -> Result; + + // --- Database / schema discovery ---------------------------------------- + + async fn get_databases(&self, params: &ConnectionParams) -> Result, String>; + async fn get_schemas(&self, params: &ConnectionParams) -> Result, String>; + + // --- Schema inspection --------------------------------------------------- + + async fn get_tables( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_columns( + &self, + params: &ConnectionParams, + table: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_foreign_keys( + &self, + params: &ConnectionParams, + table: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_indexes( + &self, + params: &ConnectionParams, + table: &str, + schema: Option<&str>, + ) -> Result, String>; + + // --- Views -------------------------------------------------------------- + + async fn get_views( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_view_definition( + &self, + params: &ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result; + + async fn get_view_columns( + &self, + params: &ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn create_view( + &self, + params: &ConnectionParams, + view_name: &str, + definition: &str, + schema: Option<&str>, + ) -> Result<(), String>; + + async fn alter_view( + &self, + params: &ConnectionParams, + view_name: &str, + definition: &str, + schema: Option<&str>, + ) -> Result<(), String>; + + async fn drop_view( + &self, + params: &ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result<(), String>; + + // --- Routines ----------------------------------------------------------- + + async fn get_routines( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_routine_parameters( + &self, + params: &ConnectionParams, + routine_name: &str, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_routine_definition( + &self, + params: &ConnectionParams, + routine_name: &str, + routine_type: &str, + schema: Option<&str>, + ) -> Result; + + // --- Query execution ---------------------------------------------------- + + async fn execute_query( + &self, + params: &ConnectionParams, + query: &str, + limit: Option, + page: u32, + schema: Option<&str>, + ) -> Result; + + // --- CRUD --------------------------------------------------------------- + + async fn insert_record( + &self, + params: &ConnectionParams, + table: &str, + data: serde_json::Value, + schema: Option<&str>, + ) -> Result; + + async fn update_record( + &self, + params: &ConnectionParams, + table: &str, + pk_col: &str, + pk_val: serde_json::Value, + col_name: &str, + new_val: serde_json::Value, + schema: Option<&str>, + ) -> Result; + + async fn delete_record( + &self, + params: &ConnectionParams, + table: &str, + pk_col: &str, + pk_val: serde_json::Value, + schema: Option<&str>, + ) -> Result; + + // --- ER diagram (batch) ------------------------------------------------- + + async fn get_schema_snapshot( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result, String>; + + async fn get_all_columns_batch( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result>, String>; + + async fn get_all_foreign_keys_batch( + &self, + params: &ConnectionParams, + schema: Option<&str>, + ) -> Result>, String>; +} +``` + +--- + +## 2. `src-tauri/src/drivers/registry.rs` — New file + +```rust +use std::collections::HashMap; +use std::sync::Arc; + +use once_cell::sync::Lazy; +use tokio::sync::RwLock; + +use super::driver_trait::{DatabaseDriver, PluginManifest}; + +type Registry = Arc>>>; + +static REGISTRY: Lazy = + Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); + +/// Register a driver. Called once at application startup for each built-in +/// driver, and can be called again at any point to add third-party drivers. +pub async fn register_driver(driver: impl DatabaseDriver + 'static) { + let id = driver.manifest().id.clone(); + log::info!("Registering driver: {} ({})", driver.manifest().name, id); + let mut reg = REGISTRY.write().await; + reg.insert(id, Arc::new(driver)); +} + +/// Look up a driver by its `id` (matches `ConnectionParams.driver`). +/// Returns `None` if no driver with that id is registered. +pub async fn get_driver(id: &str) -> Option> { + let reg = REGISTRY.read().await; + reg.get(id).cloned() +} + +/// Returns the manifests of all registered drivers, sorted by id. +/// Called by the `get_registered_drivers` Tauri command. +pub async fn list_drivers() -> Vec { + let reg = REGISTRY.read().await; + let mut manifests: Vec = + reg.values().map(|d| d.manifest().clone()).collect(); + manifests.sort_by(|a, b| a.id.cmp(&b.id)); + manifests +} +``` + +--- + +## 3. `src-tauri/src/drivers/mysql/mod.rs` — Append to existing file + +All existing `pub async fn` functions stay exactly as they are. Add this block at the bottom. + +```rust +// ============================================================ +// Plugin wrapper +// ============================================================ + +use crate::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct MysqlDriver { + manifest: PluginManifest, +} + +impl MysqlDriver { + pub fn new() -> Self { + Self { + manifest: PluginManifest { + id: "mysql".to_string(), + name: "MySQL".to_string(), + version: "1.0.0".to_string(), + description: "MySQL and MariaDB databases".to_string(), + default_port: Some(3306), + capabilities: DriverCapabilities { + schemas: false, + views: true, + routines: true, + file_based: false, + }, + }, + } + } +} + +#[async_trait] +impl DatabaseDriver for MysqlDriver { + fn manifest(&self) -> &PluginManifest { &self.manifest } + + fn get_data_types(&self) -> Vec { + types::get_data_types() + } + + fn build_connection_url(&self, params: &crate::models::ConnectionParams) -> Result { + use urlencoding::encode; + let user = encode(params.username.as_deref().unwrap_or_default()); + let pass = encode(params.password.as_deref().unwrap_or_default()); + Ok(format!( + "mysql://{}:{}@{}:{}/{}", + user, pass, + params.host.as_deref().unwrap_or("localhost"), + params.port.unwrap_or(3306), + params.database + )) + } + + async fn get_databases(&self, params: &crate::models::ConnectionParams) -> Result, String> { + // MySQL requires connecting to information_schema to list databases + let mut p = params.clone(); + p.database = "information_schema".to_string(); + p.connection_id = None; // avoid caching under the real connection key + get_databases(&p).await + } + + async fn get_schemas(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_schemas(params).await + } + + async fn get_tables(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_tables(params).await + } + + async fn get_columns(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_columns(params, table).await + } + + async fn get_foreign_keys(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_foreign_keys(params, table).await + } + + async fn get_indexes(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_indexes(params, table).await + } + + async fn get_views(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_views(params).await + } + + async fn get_view_definition(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result { + get_view_definition(params, view_name).await + } + + async fn get_view_columns(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result, String> { + get_view_columns(params, view_name).await + } + + async fn create_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + create_view(params, view_name, definition).await + } + + async fn alter_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + alter_view(params, view_name, definition).await + } + + async fn drop_view(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result<(), String> { + drop_view(params, view_name).await + } + + async fn get_routines(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_routines(params).await + } + + async fn get_routine_parameters(&self, params: &crate::models::ConnectionParams, routine_name: &str, _schema: Option<&str>) -> Result, String> { + get_routine_parameters(params, routine_name).await + } + + async fn get_routine_definition(&self, params: &crate::models::ConnectionParams, routine_name: &str, routine_type: &str, _schema: Option<&str>) -> Result { + get_routine_definition(params, routine_name, routine_type).await + } + + async fn execute_query(&self, params: &crate::models::ConnectionParams, query: &str, limit: Option, page: u32, _schema: Option<&str>) -> Result { + execute_query(params, query, limit, page).await + } + + async fn insert_record(&self, params: &crate::models::ConnectionParams, table: &str, data: serde_json::Value, _schema: Option<&str>) -> Result { + insert_record(params, table, data).await + } + + async fn update_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, _schema: Option<&str>) -> Result { + update_record(params, table, pk_col, pk_val, col_name, new_val).await + } + + async fn delete_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>) -> Result { + delete_record(params, table, pk_col, pk_val).await + } + + async fn get_all_columns_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + get_all_columns_batch(params).await + } + + async fn get_all_foreign_keys_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + get_all_foreign_keys_batch(params).await + } + + async fn get_schema_snapshot(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + let tables = self.get_tables(params, schema).await?; + let mut columns_map = self.get_all_columns_batch(params, schema).await?; + let mut fks_map = self.get_all_foreign_keys_batch(params, schema).await?; + Ok(tables.into_iter().map(|t| crate::models::TableSchema { + name: t.name.clone(), + columns: columns_map.remove(&t.name).unwrap_or_default(), + foreign_keys: fks_map.remove(&t.name).unwrap_or_default(), + }).collect()) + } +} +``` + +--- + +## 4. `src-tauri/src/drivers/postgres/mod.rs` — Append to existing file + +```rust +// ============================================================ +// Plugin wrapper +// ============================================================ + +use crate::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct PostgresDriver { + manifest: PluginManifest, +} + +impl PostgresDriver { + pub fn new() -> Self { + Self { + manifest: PluginManifest { + id: "postgres".to_string(), + name: "PostgreSQL".to_string(), + version: "1.0.0".to_string(), + description: "PostgreSQL databases".to_string(), + default_port: Some(5432), + capabilities: DriverCapabilities { + schemas: true, + views: true, + routines: true, + file_based: false, + }, + }, + } + } + + fn resolve_schema<'a>(&self, schema: Option<&'a str>) -> &'a str { + schema.unwrap_or("public") + } +} + +#[async_trait] +impl DatabaseDriver for PostgresDriver { + fn manifest(&self) -> &PluginManifest { &self.manifest } + + fn get_data_types(&self) -> Vec { + types::get_data_types() + } + + fn build_connection_url(&self, params: &crate::models::ConnectionParams) -> Result { + use urlencoding::encode; + let user = encode(params.username.as_deref().unwrap_or_default()); + let pass = encode(params.password.as_deref().unwrap_or_default()); + Ok(format!( + "postgres://{}:{}@{}:{}/{}", + user, pass, + params.host.as_deref().unwrap_or("localhost"), + params.port.unwrap_or(5432), + params.database + )) + } + + async fn get_databases(&self, params: &crate::models::ConnectionParams) -> Result, String> { + let mut p = params.clone(); + p.database = "postgres".to_string(); + get_databases(&p).await + } + + async fn get_schemas(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_schemas(params).await + } + + async fn get_tables(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + get_tables(params, self.resolve_schema(schema)).await + } + + async fn get_columns(&self, params: &crate::models::ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + get_columns(params, table, self.resolve_schema(schema)).await + } + + async fn get_foreign_keys(&self, params: &crate::models::ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + get_foreign_keys(params, table, self.resolve_schema(schema)).await + } + + async fn get_indexes(&self, params: &crate::models::ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + get_indexes(params, table, self.resolve_schema(schema)).await + } + + async fn get_views(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + get_views(params, self.resolve_schema(schema)).await + } + + async fn get_view_definition(&self, params: &crate::models::ConnectionParams, view_name: &str, schema: Option<&str>) -> Result { + get_view_definition(params, view_name, self.resolve_schema(schema)).await + } + + async fn get_view_columns(&self, params: &crate::models::ConnectionParams, view_name: &str, schema: Option<&str>) -> Result, String> { + get_view_columns(params, view_name, self.resolve_schema(schema)).await + } + + async fn create_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String> { + create_view(params, view_name, definition, self.resolve_schema(schema)).await + } + + async fn alter_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String> { + alter_view(params, view_name, definition, self.resolve_schema(schema)).await + } + + async fn drop_view(&self, params: &crate::models::ConnectionParams, view_name: &str, schema: Option<&str>) -> Result<(), String> { + drop_view(params, view_name, self.resolve_schema(schema)).await + } + + async fn get_routines(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + get_routines(params, self.resolve_schema(schema)).await + } + + async fn get_routine_parameters(&self, params: &crate::models::ConnectionParams, routine_name: &str, schema: Option<&str>) -> Result, String> { + get_routine_parameters(params, routine_name, self.resolve_schema(schema)).await + } + + async fn get_routine_definition(&self, params: &crate::models::ConnectionParams, routine_name: &str, routine_type: &str, schema: Option<&str>) -> Result { + get_routine_definition(params, routine_name, routine_type, self.resolve_schema(schema)).await + } + + async fn execute_query(&self, params: &crate::models::ConnectionParams, query: &str, limit: Option, page: u32, schema: Option<&str>) -> Result { + execute_query(params, query, limit, page, schema).await + } + + async fn insert_record(&self, params: &crate::models::ConnectionParams, table: &str, data: serde_json::Value, schema: Option<&str>) -> Result { + insert_record(params, table, data, self.resolve_schema(schema)).await + } + + async fn update_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, schema: Option<&str>) -> Result { + update_record(params, table, pk_col, pk_val, col_name, new_val, self.resolve_schema(schema)).await + } + + async fn delete_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, schema: Option<&str>) -> Result { + delete_record(params, table, pk_col, pk_val, self.resolve_schema(schema)).await + } + + async fn get_all_columns_batch(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result>, String> { + get_all_columns_batch(params, self.resolve_schema(schema)).await + } + + async fn get_all_foreign_keys_batch(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result>, String> { + get_all_foreign_keys_batch(params, self.resolve_schema(schema)).await + } + + async fn get_schema_snapshot(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + let pg_schema = self.resolve_schema(schema); + let tables = get_tables(params, pg_schema).await?; + let mut columns_map = get_all_columns_batch(params, pg_schema).await?; + let mut fks_map = get_all_foreign_keys_batch(params, pg_schema).await?; + Ok(tables.into_iter().map(|t| crate::models::TableSchema { + name: t.name.clone(), + columns: columns_map.remove(&t.name).unwrap_or_default(), + foreign_keys: fks_map.remove(&t.name).unwrap_or_default(), + }).collect()) + } +} +``` + +--- + +## 5. `src-tauri/src/drivers/sqlite/mod.rs` — Append to existing file + +```rust +// ============================================================ +// Plugin wrapper +// ============================================================ + +use crate::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct SqliteDriver { + manifest: PluginManifest, +} + +impl SqliteDriver { + pub fn new() -> Self { + Self { + manifest: PluginManifest { + id: "sqlite".to_string(), + name: "SQLite".to_string(), + version: "1.0.0".to_string(), + description: "SQLite file-based databases".to_string(), + default_port: None, + capabilities: DriverCapabilities { + schemas: false, + views: true, + routines: false, + file_based: true, + }, + }, + } + } +} + +#[async_trait] +impl DatabaseDriver for SqliteDriver { + fn manifest(&self) -> &PluginManifest { &self.manifest } + + fn get_data_types(&self) -> Vec { + types::get_data_types() + } + + fn build_connection_url(&self, params: &crate::models::ConnectionParams) -> Result { + Ok(format!("sqlite://{}", params.database)) + } + + async fn get_databases(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_databases(params).await + } + + async fn get_schemas(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_schemas(params).await + } + + async fn get_tables(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_tables(params).await + } + + async fn get_columns(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_columns(params, table).await + } + + async fn get_foreign_keys(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_foreign_keys(params, table).await + } + + async fn get_indexes(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_indexes(params, table).await + } + + async fn get_views(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_views(params).await + } + + async fn get_view_definition(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result { + get_view_definition(params, view_name).await + } + + async fn get_view_columns(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result, String> { + get_view_columns(params, view_name).await + } + + async fn create_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + create_view(params, view_name, definition).await + } + + async fn alter_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + alter_view(params, view_name, definition).await + } + + async fn drop_view(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result<(), String> { + drop_view(params, view_name).await + } + + async fn get_routines(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_routines(params).await + } + + async fn get_routine_parameters(&self, params: &crate::models::ConnectionParams, routine_name: &str, _schema: Option<&str>) -> Result, String> { + get_routine_parameters(params, routine_name).await + } + + async fn get_routine_definition(&self, params: &crate::models::ConnectionParams, routine_name: &str, routine_type: &str, _schema: Option<&str>) -> Result { + get_routine_definition(params, routine_name, routine_type).await + } + + async fn execute_query(&self, params: &crate::models::ConnectionParams, query: &str, limit: Option, page: u32, _schema: Option<&str>) -> Result { + execute_query(params, query, limit, page).await + } + + async fn insert_record(&self, params: &crate::models::ConnectionParams, table: &str, data: serde_json::Value, _schema: Option<&str>) -> Result { + insert_record(params, table, data).await + } + + async fn update_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, _schema: Option<&str>) -> Result { + update_record(params, table, pk_col, pk_val, col_name, new_val).await + } + + async fn delete_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>) -> Result { + delete_record(params, table, pk_col, pk_val).await + } + + async fn get_all_columns_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + let tables = get_tables(params).await?; + let names: Vec = tables.into_iter().map(|t| t.name).collect(); + get_all_columns_batch(params, &names).await + } + + async fn get_all_foreign_keys_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + let tables = get_tables(params).await?; + let names: Vec = tables.into_iter().map(|t| t.name).collect(); + get_all_foreign_keys_batch(params, &names).await + } + + async fn get_schema_snapshot(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + let tables = get_tables(params).await?; + let names: Vec = tables.iter().map(|t| t.name.clone()).collect(); + let mut columns_map = get_all_columns_batch(params, &names).await?; + let mut fks_map = get_all_foreign_keys_batch(params, &names).await?; + Ok(tables.into_iter().map(|t| crate::models::TableSchema { + name: t.name.clone(), + columns: columns_map.remove(&t.name).unwrap_or_default(), + foreign_keys: fks_map.remove(&t.name).unwrap_or_default(), + }).collect()) + } +} +``` + +--- + +## 6. `src-tauri/src/lib.rs` — Changes + +### Expose new modules + +```rust +// Before: +pub mod drivers { + pub mod common; + pub mod mysql; + pub mod postgres; + pub mod sqlite; +} + +// After: +pub mod drivers { + pub mod common; + pub mod driver_trait; + pub mod registry; + pub mod mysql; + pub mod postgres; + pub mod sqlite; +} +``` + +### Register built-in drivers inside `.setup()` + +```rust +.setup(move |app| { + // Register built-in drivers + tauri::async_runtime::block_on(async { + drivers::registry::register_driver(drivers::mysql::MysqlDriver::new()).await; + drivers::registry::register_driver(drivers::postgres::PostgresDriver::new()).await; + drivers::registry::register_driver(drivers::sqlite::SqliteDriver::new()).await; + }); + + if args.debug { + if let Some(window) = app.get_webview_window("main") { + window.open_devtools(); + } + } + Ok(()) +}) +``` + +### Add new command to `invoke_handler![]` + +```rust +commands::get_registered_drivers, +``` + +--- + +## 7. `src-tauri/src/commands.rs` — Changes + +### Add helper function (near the top, after imports) + +```rust +/// Resolve the driver from the registry or return a descriptive error. +async fn driver_for( + id: &str, +) -> Result, String> { + crate::drivers::registry::get_driver(id) + .await + .ok_or_else(|| format!("Unsupported driver: {}", id)) +} +``` + +### Replace every `match driver` dispatch + +**Before (repeated ~20 times):** +```rust +match saved_conn.params.driver.as_str() { + "mysql" => mysql::get_tables(¶ms).await, + "postgres" => postgres::get_tables(¶ms, schema.as_deref().unwrap_or("public")).await, + "sqlite" => sqlite::get_tables(¶ms).await, + _ => Err("Unsupported driver".into()), +} +``` + +**After:** +```rust +let drv = driver_for(&saved_conn.params.driver).await?; +drv.get_tables(¶ms, schema.as_deref()).await +``` + +Full substitution table: + +| Command function | New dispatch call | +|-----------------|-------------------| +| `get_schemas` | `drv.get_schemas(¶ms).await` | +| `get_tables` | `drv.get_tables(¶ms, schema.as_deref()).await` | +| `get_columns` | `drv.get_columns(¶ms, &table_name, schema.as_deref()).await` | +| `get_foreign_keys` | `drv.get_foreign_keys(¶ms, &table_name, schema.as_deref()).await` | +| `get_indexes` | `drv.get_indexes(¶ms, &table_name, schema.as_deref()).await` | +| `get_views` | `drv.get_views(¶ms, schema.as_deref()).await` | +| `get_view_definition` | `drv.get_view_definition(¶ms, &view_name, schema.as_deref()).await` | +| `get_view_columns` | `drv.get_view_columns(¶ms, &view_name, schema.as_deref()).await` | +| `create_view` | `drv.create_view(¶ms, &view_name, &definition, schema.as_deref()).await` | +| `alter_view` | `drv.alter_view(¶ms, &view_name, &definition, schema.as_deref()).await` | +| `drop_view` | `drv.drop_view(¶ms, &view_name, schema.as_deref()).await` | +| `get_routines` | `drv.get_routines(¶ms, schema.as_deref()).await` | +| `get_routine_parameters` | `drv.get_routine_parameters(¶ms, &routine_name, schema.as_deref()).await` | +| `get_routine_definition` | `drv.get_routine_definition(¶ms, &routine_name, &routine_type, schema.as_deref()).await` | +| `insert_record` | `drv.insert_record(¶ms, &table, data, schema.as_deref()).await` | +| `update_record` | `drv.update_record(¶ms, &table, &pk_col, pk_val, &col_name, new_val, schema.as_deref()).await` | +| `delete_record` | `drv.delete_record(¶ms, &table, &pk_col, pk_val, schema.as_deref()).await` | +| `get_schema_snapshot` | `drv.get_schema_snapshot(¶ms, schema.as_deref()).await` | + +### Special case — `execute_query` (spawns a cancellable task) + +`Arc` is `Send`, so it can be moved into `tokio::spawn`: + +```rust +let drv = driver_for(&saved_conn.params.driver).await?; +let task = tokio::spawn(async move { + drv.execute_query(¶ms, &sanitized_query, limit, page.unwrap_or(1), schema.as_deref()).await +}); +``` + +### Special case — `list_databases` + +The database-switching logic (e.g. MySQL connecting to `information_schema`) is now +encapsulated inside `MysqlDriver::get_databases` and `PostgresDriver::get_databases`: + +```rust +let drv = driver_for(&resolved_params.driver).await?; +drv.get_databases(&resolved_params).await +``` + +### Special case — `get_data_types` + +```rust +#[tauri::command] +pub async fn get_data_types(driver: String) -> crate::models::DataTypeRegistry { + let types = match crate::drivers::registry::get_driver(&driver).await { + Some(drv) => drv.get_data_types(), + None => { + log::warn!("Unknown driver: {}, returning empty type list", driver); + vec![] + } + }; + crate::models::DataTypeRegistry { driver, types } +} +``` + +### Special case — `build_connection_url` + +```rust +pub async fn build_connection_url(params: &ConnectionParams) -> Result { + let drv = driver_for(¶ms.driver).await?; + drv.build_connection_url(params) +} +``` + +### New command + +```rust +#[tauri::command] +pub async fn get_registered_drivers() -> Vec { + crate::drivers::registry::list_drivers().await +} +``` + +--- + +## 8. `src/types/plugins.ts` — New file + +```typescript +export interface DriverCapabilities { + schemas: boolean; + views: boolean; + routines: boolean; + file_based: boolean; +} + +export interface PluginManifest { + id: string; + name: string; + version: string; + description: string; + default_port: number | null; + capabilities: DriverCapabilities; +} +``` + +--- + +## 9. `src/hooks/useDrivers.ts` — New file + +```typescript +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; + +import type { PluginManifest } from "../types/plugins"; + +const FALLBACK_DRIVERS: PluginManifest[] = [ + { + id: "postgres", + name: "PostgreSQL", + version: "1.0.0", + description: "PostgreSQL databases", + default_port: 5432, + capabilities: { schemas: true, views: true, routines: true, file_based: false }, + }, + { + id: "mysql", + name: "MySQL", + version: "1.0.0", + description: "MySQL and MariaDB databases", + default_port: 3306, + capabilities: { schemas: false, views: true, routines: true, file_based: false }, + }, + { + id: "sqlite", + name: "SQLite", + version: "1.0.0", + description: "SQLite file-based databases", + default_port: null, + capabilities: { schemas: false, views: true, routines: false, file_based: true }, + }, +]; + +export function useDrivers(): { + drivers: PluginManifest[]; + loading: boolean; + error: string | null; +} { + const [drivers, setDrivers] = useState(FALLBACK_DRIVERS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + invoke("get_registered_drivers") + .then((result) => { + setDrivers(result); + setError(null); + }) + .catch((err: unknown) => { + setError(String(err)); + }) + .finally(() => setLoading(false)); + }, []); + + return { drivers, loading, error }; +} +``` + +--- + +## 10. `src/utils/connections.ts` — Changes + +```typescript +// Before: +export type DatabaseDriver = "postgres" | "mysql" | "sqlite"; + +// After: +export type DatabaseDriver = string; + +// Keep these for places in the codebase that check against known built-in ids: +export const BUILTIN_DRIVER_IDS = ["postgres", "mysql", "sqlite"] as const; +export type BuiltinDriverId = (typeof BUILTIN_DRIVER_IDS)[number]; +``` + +No other changes needed — `getDefaultPort`, `getDriverLabel`, `validateConnectionParams`, +`formatConnectionString`, and `generateConnectionName` all have `default` branches that +already handle unknown driver strings gracefully. + +--- + +## 11. `src/components/ui/NewConnectionModal.tsx` — Changes + +```typescript +// Remove the local type alias: +// type Driver = "postgres" | "mysql" | "sqlite"; ← delete this + +// Add imports: +import { useDrivers } from "../../hooks/useDrivers"; +import type { PluginManifest } from "../../types/plugins"; + +// Inside the component, replace the hard-coded useState: +const { drivers } = useDrivers(); +const [selectedDriverId, setSelectedDriverId] = useState("postgres"); +const activeDriver = drivers.find((d) => d.id === selectedDriverId) ?? drivers[0]; + +// Replace the static driver button list: +// Before: +// {(["mysql", "postgres", "sqlite"] as Driver[]).map((d) => (...))} + +// After: +{drivers.map((d: PluginManifest) => ( + +))} + +// Replace driver string comparisons with capability checks where possible: +// driver !== "sqlite" → activeDriver?.capabilities.file_based !== true +// driver === "postgres" → activeDriver?.capabilities.schemas === true +// driver === "sqlite" → activeDriver?.capabilities.file_based === true +``` + +--- + +## Third-party plugin guide + +To ship a new driver (e.g. ClickHouse): + +### 1. Depend on `tabularis_lib` + +```toml +[dependencies] +tabularis_lib = { path = "../tabularis" } +async-trait = "0.1" +clickhouse = "0.11" +``` + +### 2. Implement the trait + +```rust +use async_trait::async_trait; +use tabularis_lib::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest}; +use tabularis_lib::models::*; + +pub struct ClickHouseDriver { manifest: PluginManifest } + +impl ClickHouseDriver { + pub fn new() -> Self { + Self { + manifest: PluginManifest { + id: "clickhouse".to_string(), + name: "ClickHouse".to_string(), + version: "0.1.0".to_string(), + description: "ClickHouse OLAP database".to_string(), + default_port: Some(8123), + capabilities: DriverCapabilities { + schemas: false, + views: true, + routines: false, + file_based: false, + }, + }, + } + } +} + +#[async_trait] +impl DatabaseDriver for ClickHouseDriver { + fn manifest(&self) -> &PluginManifest { &self.manifest } + // ... implement all required methods +} +``` + +### 3. Register at startup — one line in `lib.rs` + +```rust +drivers::registry::register_driver(clickhouse_driver::ClickHouseDriver::new()).await; +``` + +The frontend picks up the new driver automatically via `get_registered_drivers` — +no frontend changes required. + +--- + +## What stays unchanged + +| Component | Status | +|-----------|--------| +| SQL logic inside `mysql/mod.rs`, `postgres/mod.rs`, `sqlite/mod.rs` | Unchanged | +| `models.rs` | Unchanged | +| `pool_manager.rs` | Unchanged | +| SSH tunnel handling | Unchanged | +| Export, dump, MCP, AI commands | Unchanged | +| Existing Rust tests | Unchanged | diff --git a/src-tauri/src/drivers/plugin-ecosystem-plan.md b/src-tauri/src/drivers/plugin-ecosystem-plan.md new file mode 100644 index 0000000..e800255 --- /dev/null +++ b/src-tauri/src/drivers/plugin-ecosystem-plan.md @@ -0,0 +1,261 @@ +# Plugin Ecosystem Plan — Database Drivers + +## Current State + +Driver dispatch is hard-coded in `commands.rs` with repeated `match` blocks: + +```rust +match driver { + "mysql" => mysql::get_tables(¶ms).await, + "postgres" => postgres::get_tables(¶ms, schema).await, + "sqlite" => sqlite::get_tables(¶ms).await, + _ => Err("Unsupported driver"), +} +``` + +This pattern repeats ~20 times. Adding a new driver requires touching `commands.rs`, `lib.rs`, and `pool_manager.rs`. + +--- + +## Chosen Approach: Trait-based Registry (static, compile-time safe) + +**Why not dynamic libraries (`.so`/`.dylib`):** +- Rust has no stable ABI — a plugin compiled with a different compiler version will crash +- Requires `unsafe` code and manual memory management +- Incompatible with the Tauri desktop model + +**Chosen alternative:** each driver is a `struct` implementing a `DatabaseDriver` trait. A global registry tracks registered drivers. To add a custom driver, write a Rust crate implementing the trait and register it at startup. + +--- + +## Files to Create (Rust Backend) + +### 1. `src-tauri/src/drivers/driver_trait.rs` + +Defines the core types and trait: + +```rust +pub struct PluginManifest { + pub id: String, // "mysql", "my-clickhouse-driver" + pub name: String, // "MySQL", "ClickHouse" + pub version: String, // semver string + pub description: String, + pub default_port: Option, + pub capabilities: DriverCapabilities, +} + +pub struct DriverCapabilities { + pub schemas: bool, // supports multiple schemas (e.g. PostgreSQL) + pub views: bool, + pub routines: bool, + pub file_based: bool, // e.g. SQLite +} + +#[async_trait] +pub trait DatabaseDriver: Send + Sync { + // --- Metadata --- + fn manifest(&self) -> &PluginManifest; + fn get_data_types(&self) -> Vec; + fn build_connection_url(&self, params: &ConnectionParams) -> Result; + + // --- Database discovery --- + async fn get_databases(&self, params: &ConnectionParams) -> Result, String>; + async fn get_schemas(&self, params: &ConnectionParams) -> Result, String>; + + // --- Schema inspection --- + async fn get_tables(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String>; + async fn get_columns(&self, params: &ConnectionParams, table: &str, schema: Option<&str>) -> Result, String>; + async fn get_foreign_keys(&self, params: &ConnectionParams, table: &str, schema: Option<&str>) -> Result, String>; + async fn get_indexes(&self, params: &ConnectionParams, table: &str, schema: Option<&str>) -> Result, String>; + + // --- Views --- + async fn get_views(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String>; + async fn get_view_definition(&self, params: &ConnectionParams, view_name: &str, schema: Option<&str>) -> Result; + async fn get_view_columns(&self, params: &ConnectionParams, view_name: &str, schema: Option<&str>) -> Result, String>; + async fn create_view(&self, params: &ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String>; + async fn alter_view(&self, params: &ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String>; + async fn drop_view(&self, params: &ConnectionParams, view_name: &str, schema: Option<&str>) -> Result<(), String>; + + // --- Routines --- + async fn get_routines(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String>; + async fn get_routine_parameters(&self, params: &ConnectionParams, routine_name: &str, schema: Option<&str>) -> Result, String>; + async fn get_routine_definition(&self, params: &ConnectionParams, routine_name: &str, routine_type: &str, schema: Option<&str>) -> Result; + + // --- Query execution --- + async fn execute_query(&self, params: &ConnectionParams, query: &str, limit: u32, page: u32) -> Result; + + // --- CRUD --- + async fn insert_record(&self, params: &ConnectionParams, table: &str, data: serde_json::Value, schema: Option<&str>) -> Result; + async fn update_record(&self, params: &ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, schema: Option<&str>) -> Result; + async fn delete_record(&self, params: &ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, schema: Option<&str>) -> Result; + + // --- ER Diagram (batch for performance) --- + async fn get_schema_snapshot(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String>; +} +``` + +> The `schema: Option<&str>` parameter is uniform across all methods. Each driver uses its own appropriate default when `None` is passed (e.g. PostgreSQL defaults to `"public"`, MySQL and SQLite ignore it). + +--- + +### 2. `src-tauri/src/drivers/registry.rs` + +```rust +static DRIVER_REGISTRY: Lazy>>> + +pub fn register_driver(driver: impl DatabaseDriver + 'static) +pub fn get_driver(id: &str) -> Option> +pub fn list_drivers() -> Vec +``` + +Thread-safe via `tokio::sync::RwLock`. Built-in drivers are registered at app startup in `lib.rs`. + +--- + +### 3. Built-in driver wrappers + +Three new structs, one per driver module: + +| File | Struct | +|------|--------| +| `src-tauri/src/drivers/mysql/mod.rs` | `pub struct MysqlDriver { manifest: PluginManifest }` | +| `src-tauri/src/drivers/postgres/mod.rs` | `pub struct PostgresDriver { manifest: PluginManifest }` | +| `src-tauri/src/drivers/sqlite/mod.rs` | `pub struct SqliteDriver { manifest: PluginManifest }` | + +Each implements `DatabaseDriver` by delegating to the existing `pub async fn` functions already in the module. **No logic is duplicated** — the wrappers are thin adapters. + +--- + +### 4. `src-tauri/src/lib.rs` + +- Exposes the new modules: `driver_trait`, `registry` +- At app startup, before building the Tauri builder: + +```rust +drivers::registry::register_driver(drivers::mysql::MysqlDriver::new()); +drivers::registry::register_driver(drivers::postgres::PostgresDriver::new()); +drivers::registry::register_driver(drivers::sqlite::SqliteDriver::new()); +``` + +--- + +### 5. `src-tauri/src/commands.rs` + +Every `match driver { ... }` block is replaced with: + +```rust +let driver = registry::get_driver(&saved_conn.params.driver) + .ok_or_else(|| format!("Unsupported driver: {}", saved_conn.params.driver))?; + +driver.get_tables(¶ms, schema.as_deref()).await +``` + +New Tauri command added: + +```rust +#[tauri::command] +pub fn get_registered_drivers() -> Vec { + registry::list_drivers() +} +``` + +Registered in `lib.rs` inside `invoke_handler![]`. + +--- + +## Files to Modify (TypeScript Frontend) + +### 6. `src/utils/connections.ts` + +```typescript +// Before (closed union type): +export type DatabaseDriver = "postgres" | "mysql" | "sqlite"; + +// After (open, backward compatible): +export type DatabaseDriver = string; + +// Keep constant helpers for built-in drivers: +export const BUILTIN_DRIVERS = ["postgres", "mysql", "sqlite"] as const; +export type BuiltinDriver = (typeof BUILTIN_DRIVERS)[number]; +``` + +`getDefaultPort`, `getDriverLabel`, and `validateConnectionParams` continue to work — their `default` cases already return a generic value. + +--- + +### 7. `src/types/plugins.ts` (new file) + +```typescript +export interface DriverCapabilities { + schemas: boolean; + views: boolean; + routines: boolean; + file_based: boolean; +} + +export interface PluginManifest { + id: string; + name: string; + version: string; + description: string; + default_port: number | null; + capabilities: DriverCapabilities; +} +``` + +--- + +### 8. `src/hooks/useDrivers.ts` (new file) + +```typescript +export function useDrivers(): { + drivers: PluginManifest[]; + loading: boolean; + error: string | null; +} +// Calls invoke("get_registered_drivers") → PluginManifest[] +// Result drives the driver selection UI in NewConnectionModal +``` + +--- + +### 9. `src/components/ui/NewConnectionModal.tsx` + +The local `type Driver = "postgres" | "mysql" | "sqlite"` is removed. The driver list is loaded dynamically via `useDrivers()`. While loading, a fallback to the three built-in drivers is shown. + +--- + +## How to Add a Custom Driver (end result) + +An external contributor will need to: + +1. Create a separate Rust crate with a dependency on `tabularis_lib` +2. Define a `struct MyDriver` and implement `DatabaseDriver` +3. In `lib.rs`, add one line: + ```rust + registry::register_driver(my_crate::MyDriver::new()); + ``` +4. The frontend automatically shows the new driver in the connection form + +--- + +## What Does NOT Change + +| Area | Status | +|------|--------| +| SQL logic inside existing driver modules | Unchanged | +| `pool_manager.rs` | Unchanged (built-in drivers continue using it internally) | +| `models.rs` data structures | Unchanged | +| Existing tests | Continue to compile | + +--- + +## Implementation Order + +1. `driver_trait.rs` — defines the contract +2. `registry.rs` — infrastructure +3. `MysqlDriver`, `PostgresDriver`, `SqliteDriver` wrappers +4. `lib.rs` update (driver registration) +5. `commands.rs` update (dispatch via registry + new `get_registered_drivers` command) +6. Frontend: `plugins.ts`, `useDrivers.ts`, `connections.ts`, `NewConnectionModal.tsx` +7. `drivers/README.md` update diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 6c5dad6..60cb146 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1145,3 +1145,413 @@ pub async fn get_routine_definition( let definition: String = row.try_get("definition").unwrap_or_default(); Ok(definition) } + +// ============================================================ +// Plugin wrapper +// ============================================================ + +use crate::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct PostgresDriver { + manifest: PluginManifest, +} + +impl PostgresDriver { + pub fn new() -> Self { + Self { + manifest: PluginManifest { + id: "postgres".to_string(), + name: "PostgreSQL".to_string(), + version: "1.0.0".to_string(), + description: "PostgreSQL databases".to_string(), + default_port: Some(5432), + capabilities: DriverCapabilities { + schemas: true, + views: true, + routines: true, + file_based: false, + identifier_quote: "\"".into(), + }, + }, + } + } + + fn resolve_schema<'a>(&self, schema: Option<&'a str>) -> &'a str { + schema.unwrap_or("public") + } +} + +#[async_trait] +impl DatabaseDriver for PostgresDriver { + fn manifest(&self) -> &PluginManifest { &self.manifest } + + fn get_data_types(&self) -> Vec { + types::get_data_types() + } + + fn build_connection_url(&self, params: &crate::models::ConnectionParams) -> Result { + use urlencoding::encode; + let user = encode(params.username.as_deref().unwrap_or_default()); + let pass = encode(params.password.as_deref().unwrap_or_default()); + Ok(format!( + "postgres://{}:{}@{}:{}/{}", + user, pass, + params.host.as_deref().unwrap_or("localhost"), + params.port.unwrap_or(5432), + params.database + )) + } + + async fn get_databases(&self, params: &crate::models::ConnectionParams) -> Result, String> { + let mut p = params.clone(); + p.database = "postgres".to_string(); + get_databases(&p).await + } + + async fn get_schemas(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_schemas(params).await + } + + async fn get_tables(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + get_tables(params, self.resolve_schema(schema)).await + } + + async fn get_columns(&self, params: &crate::models::ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + get_columns(params, table, self.resolve_schema(schema)).await + } + + async fn get_foreign_keys(&self, params: &crate::models::ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + get_foreign_keys(params, table, self.resolve_schema(schema)).await + } + + async fn get_indexes(&self, params: &crate::models::ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + get_indexes(params, table, self.resolve_schema(schema)).await + } + + async fn get_views(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + get_views(params, self.resolve_schema(schema)).await + } + + async fn get_view_definition(&self, params: &crate::models::ConnectionParams, view_name: &str, schema: Option<&str>) -> Result { + get_view_definition(params, view_name, self.resolve_schema(schema)).await + } + + async fn get_view_columns(&self, params: &crate::models::ConnectionParams, view_name: &str, schema: Option<&str>) -> Result, String> { + get_view_columns(params, view_name, self.resolve_schema(schema)).await + } + + async fn create_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String> { + create_view(params, view_name, definition, self.resolve_schema(schema)).await + } + + async fn alter_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String> { + alter_view(params, view_name, definition, self.resolve_schema(schema)).await + } + + async fn drop_view(&self, params: &crate::models::ConnectionParams, view_name: &str, schema: Option<&str>) -> Result<(), String> { + drop_view(params, view_name, self.resolve_schema(schema)).await + } + + async fn get_routines(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + get_routines(params, self.resolve_schema(schema)).await + } + + async fn get_routine_parameters(&self, params: &crate::models::ConnectionParams, routine_name: &str, schema: Option<&str>) -> Result, String> { + get_routine_parameters(params, routine_name, self.resolve_schema(schema)).await + } + + async fn get_routine_definition(&self, params: &crate::models::ConnectionParams, routine_name: &str, routine_type: &str, schema: Option<&str>) -> Result { + get_routine_definition(params, routine_name, routine_type, self.resolve_schema(schema)).await + } + + async fn execute_query(&self, params: &crate::models::ConnectionParams, query: &str, limit: Option, page: u32, schema: Option<&str>) -> Result { + execute_query(params, query, limit, page, schema).await + } + + async fn insert_record(&self, params: &crate::models::ConnectionParams, table: &str, data: std::collections::HashMap, schema: Option<&str>, max_blob_size: u64) -> Result { + insert_record(params, table, data, self.resolve_schema(schema), max_blob_size).await + } + + async fn update_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, schema: Option<&str>, max_blob_size: u64) -> Result { + update_record(params, table, pk_col, pk_val, col_name, new_val, self.resolve_schema(schema), max_blob_size).await + } + + async fn delete_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, schema: Option<&str>) -> Result { + delete_record(params, table, pk_col, pk_val, self.resolve_schema(schema)).await + } + + async fn save_blob_to_file(&self, params: &crate::models::ConnectionParams, table: &str, col_name: &str, pk_col: &str, pk_val: serde_json::Value, schema: Option<&str>, file_path: &str) -> Result<(), String> { + save_blob_column_to_file(params, table, col_name, pk_col, pk_val, self.resolve_schema(schema), file_path).await + } + + async fn fetch_blob_as_data_url(&self, params: &crate::models::ConnectionParams, table: &str, col_name: &str, pk_col: &str, pk_val: serde_json::Value, schema: Option<&str>) -> Result { + fetch_blob_column_as_data_url(params, table, col_name, pk_col, pk_val, self.resolve_schema(schema)).await + } + + async fn get_create_table_sql( + &self, + table_name: &str, + columns: Vec, + schema: Option<&str>, + ) -> Result, String> { + let pg_schema = self.resolve_schema(schema); + let mut col_defs = Vec::new(); + let mut pk_cols = Vec::new(); + for col in &columns { + let type_str = if col.is_auto_increment { + let upper = col.data_type.to_uppercase(); + if upper.contains("BIGINT") || upper.contains("BIGSERIAL") { + "BIGSERIAL".to_string() + } else { + "SERIAL".to_string() + } + } else { + col.data_type.clone() + }; + let mut def = format!("\"{}\" {}", col.name.replace('"', "\"\""), type_str); + if !col.is_nullable && !col.is_auto_increment { + def.push_str(" NOT NULL"); + } + if let Some(default) = &col.default_value { + if !col.is_auto_increment { + def.push_str(&format!(" DEFAULT {}", default)); + } + } + col_defs.push(def); + if col.is_pk { + pk_cols.push(format!("\"{}\"", col.name.replace('"', "\"\""))); + } + } + if !pk_cols.is_empty() { + col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); + } + Ok(vec![format!( + "CREATE TABLE \"{}\".\"{}\" (\n {}\n)", + pg_schema.replace('"', "\"\""), + table_name.replace('"', "\"\""), + col_defs.join(",\n ") + )]) + } + + async fn get_add_column_sql( + &self, + table: &str, + column: crate::models::ColumnDefinition, + schema: Option<&str>, + ) -> Result, String> { + let pg_schema = self.resolve_schema(schema); + let tbl = format!( + "\"{}\".\"{}\"", + pg_schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + let type_str = if column.is_auto_increment { + let upper = column.data_type.to_uppercase(); + if upper.contains("BIGINT") || upper.contains("BIGSERIAL") { + "BIGSERIAL".to_string() + } else { + "SERIAL".to_string() + } + } else { + column.data_type.clone() + }; + let mut def = format!( + "ALTER TABLE {} ADD COLUMN \"{}\" {}", + tbl, + column.name.replace('"', "\"\""), + type_str + ); + if !column.is_nullable && !column.is_auto_increment { + def.push_str(" NOT NULL"); + } + if let Some(default) = &column.default_value { + if !column.is_auto_increment { + def.push_str(&format!(" DEFAULT {}", default)); + } + } + Ok(vec![def]) + } + + async fn get_alter_column_sql( + &self, + table: &str, + old_column: crate::models::ColumnDefinition, + new_column: crate::models::ColumnDefinition, + schema: Option<&str>, + ) -> Result, String> { + let pg_schema = self.resolve_schema(schema); + let tbl = format!( + "\"{}\".\"{}\"", + pg_schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + let old_name = format!("\"{}\"", old_column.name.replace('"', "\"\"")); + let new_name_quoted = format!("\"{}\"", new_column.name.replace('"', "\"\"")); + let mut stmts = Vec::new(); + + if old_column.name != new_column.name { + stmts.push(format!( + "ALTER TABLE {} RENAME COLUMN {} TO {}", + tbl, old_name, new_name_quoted + )); + } + + let col_ref = &new_name_quoted; + + if old_column.data_type != new_column.data_type { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", + tbl, col_ref, new_column.data_type, col_ref, new_column.data_type + )); + } + + if old_column.is_nullable != new_column.is_nullable { + if new_column.is_nullable { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", + tbl, col_ref + )); + } else { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", + tbl, col_ref + )); + } + } + + if old_column.default_value != new_column.default_value { + if let Some(default) = &new_column.default_value { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", + tbl, col_ref, default + )); + } else { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", + tbl, col_ref + )); + } + } + + if stmts.is_empty() { + return Err("No changes detected".into()); + } + Ok(stmts) + } + + async fn get_create_index_sql( + &self, + table: &str, + index_name: &str, + columns: Vec, + is_unique: bool, + schema: Option<&str>, + ) -> Result, String> { + let pg_schema = self.resolve_schema(schema); + let unique = if is_unique { "UNIQUE " } else { "" }; + let cols: Vec = columns + .iter() + .map(|c| format!("\"{}\"", c.replace('"', "\"\""))) + .collect(); + Ok(vec![format!( + "CREATE {}INDEX \"{}\" ON \"{}\".\"{}\" ({})", + unique, + index_name.replace('"', "\"\""), + pg_schema.replace('"', "\"\""), + table.replace('"', "\"\""), + cols.join(", ") + )]) + } + + async fn get_create_foreign_key_sql( + &self, + table: &str, + fk_name: &str, + column: &str, + ref_table: &str, + ref_column: &str, + on_delete: Option<&str>, + on_update: Option<&str>, + schema: Option<&str>, + ) -> Result, String> { + let pg_schema = self.resolve_schema(schema); + let tbl = format!( + "\"{}\".\"{}\"", + pg_schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + let mut sql = format!( + "ALTER TABLE {} ADD CONSTRAINT \"{}\" FOREIGN KEY (\"{}\") REFERENCES \"{}\".\"{}\" (\"{}\")", + tbl, + fk_name.replace('"', "\"\""), + column.replace('"', "\"\""), + pg_schema.replace('"', "\"\""), + ref_table.replace('"', "\"\""), + ref_column.replace('"', "\"\"") + ); + if let Some(action) = on_delete { + sql.push_str(&format!(" ON DELETE {}", action)); + } + if let Some(action) = on_update { + sql.push_str(&format!(" ON UPDATE {}", action)); + } + Ok(vec![sql]) + } + + async fn drop_index( + &self, + params: &crate::models::ConnectionParams, + _table: &str, + index_name: &str, + schema: Option<&str>, + ) -> Result<(), String> { + let pg_schema = self.resolve_schema(schema); + let sql = format!( + "DROP INDEX \"{}\".\"{}\"", + pg_schema.replace('"', "\"\""), + index_name.replace('"', "\"\"") + ); + execute_query(params, &sql, None, 1, schema).await?; + Ok(()) + } + + async fn drop_foreign_key( + &self, + params: &crate::models::ConnectionParams, + table: &str, + fk_name: &str, + schema: Option<&str>, + ) -> Result<(), String> { + let pg_schema = self.resolve_schema(schema); + let sql = format!( + "ALTER TABLE \"{}\".\"{}\" DROP CONSTRAINT \"{}\"", + pg_schema.replace('"', "\"\""), + table.replace('"', "\"\""), + fk_name.replace('"', "\"\"") + ); + execute_query(params, &sql, None, 1, schema).await?; + Ok(()) + } + + async fn get_all_columns_batch(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result>, String> { + get_all_columns_batch(params, self.resolve_schema(schema)).await + } + + async fn get_all_foreign_keys_batch(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result>, String> { + get_all_foreign_keys_batch(params, self.resolve_schema(schema)).await + } + + async fn get_schema_snapshot(&self, params: &crate::models::ConnectionParams, schema: Option<&str>) -> Result, String> { + let pg_schema = self.resolve_schema(schema); + let tables = get_tables(params, pg_schema).await?; + let mut columns_map = get_all_columns_batch(params, pg_schema).await?; + let mut fks_map = get_all_foreign_keys_batch(params, pg_schema).await?; + Ok(tables.into_iter().map(|t| crate::models::TableSchema { + name: t.name.clone(), + columns: columns_map.remove(&t.name).unwrap_or_default(), + foreign_keys: fks_map.remove(&t.name).unwrap_or_default(), + }).collect()) + } +} diff --git a/src-tauri/src/drivers/registry.rs b/src-tauri/src/drivers/registry.rs new file mode 100644 index 0000000..76d3232 --- /dev/null +++ b/src-tauri/src/drivers/registry.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use once_cell::sync::Lazy; +use tokio::sync::RwLock; + +use super::driver_trait::{DatabaseDriver, PluginManifest}; + +type Registry = Arc>>>; + +static REGISTRY: Lazy = + Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); + +/// Register a driver. Called once at application startup for each built-in +/// driver, and can be called again at any point to add third-party drivers. +pub async fn register_driver(driver: impl DatabaseDriver + 'static) { + let id = driver.manifest().id.clone(); + log::info!("Registering driver: {} ({})", driver.manifest().name, id); + let mut reg = REGISTRY.write().await; + reg.insert(id, Arc::new(driver)); +} + +/// Look up a driver by its `id` (matches `ConnectionParams.driver`). +/// Returns `None` if no driver with that id is registered. +pub async fn get_driver(id: &str) -> Option> { + let reg = REGISTRY.read().await; + reg.get(id).cloned() +} + +/// Returns the manifests of all registered drivers, sorted by id. +/// Called by the `get_registered_drivers` Tauri command. +pub async fn list_drivers() -> Vec { + let reg = REGISTRY.read().await; + let mut manifests: Vec = + reg.values().map(|d| d.manifest().clone()).collect(); + manifests.sort_by(|a, b| a.id.cmp(&b.id)); + manifests +} diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 874de35..e331238 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -831,3 +831,292 @@ mod tests { crate::pool_manager::close_pool(¶ms).await; } } + +// ============================================================ +// Plugin wrapper +// ============================================================ + +use crate::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest}; +use async_trait::async_trait; +use std::collections::HashMap; + +pub struct SqliteDriver { + manifest: PluginManifest, +} + +impl SqliteDriver { + pub fn new() -> Self { + Self { + manifest: PluginManifest { + id: "sqlite".to_string(), + name: "SQLite".to_string(), + version: "1.0.0".to_string(), + description: "SQLite file-based databases".to_string(), + default_port: None, + capabilities: DriverCapabilities { + schemas: false, + views: true, + routines: false, + file_based: true, + identifier_quote: "\"".into(), + }, + }, + } + } +} + +#[async_trait] +impl DatabaseDriver for SqliteDriver { + fn manifest(&self) -> &PluginManifest { &self.manifest } + + fn get_data_types(&self) -> Vec { + types::get_data_types() + } + + fn build_connection_url(&self, params: &crate::models::ConnectionParams) -> Result { + Ok(format!("sqlite://{}", params.database)) + } + + async fn get_databases(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_databases(params).await + } + + async fn get_schemas(&self, params: &crate::models::ConnectionParams) -> Result, String> { + get_schemas(params).await + } + + async fn get_tables(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_tables(params).await + } + + async fn get_columns(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_columns(params, table).await + } + + async fn get_foreign_keys(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_foreign_keys(params, table).await + } + + async fn get_indexes(&self, params: &crate::models::ConnectionParams, table: &str, _schema: Option<&str>) -> Result, String> { + get_indexes(params, table).await + } + + async fn get_views(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_views(params).await + } + + async fn get_view_definition(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result { + get_view_definition(params, view_name).await + } + + async fn get_view_columns(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result, String> { + get_view_columns(params, view_name).await + } + + async fn create_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + create_view(params, view_name, definition).await + } + + async fn alter_view(&self, params: &crate::models::ConnectionParams, view_name: &str, definition: &str, _schema: Option<&str>) -> Result<(), String> { + alter_view(params, view_name, definition).await + } + + async fn drop_view(&self, params: &crate::models::ConnectionParams, view_name: &str, _schema: Option<&str>) -> Result<(), String> { + drop_view(params, view_name).await + } + + async fn get_routines(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + get_routines(params).await + } + + async fn get_routine_parameters(&self, params: &crate::models::ConnectionParams, routine_name: &str, _schema: Option<&str>) -> Result, String> { + get_routine_parameters(params, routine_name).await + } + + async fn get_routine_definition(&self, params: &crate::models::ConnectionParams, routine_name: &str, routine_type: &str, _schema: Option<&str>) -> Result { + get_routine_definition(params, routine_name, routine_type).await + } + + async fn execute_query(&self, params: &crate::models::ConnectionParams, query: &str, limit: Option, page: u32, _schema: Option<&str>) -> Result { + execute_query(params, query, limit, page).await + } + + async fn insert_record(&self, params: &crate::models::ConnectionParams, table: &str, data: std::collections::HashMap, _schema: Option<&str>, max_blob_size: u64) -> Result { + insert_record(params, table, data, max_blob_size).await + } + + async fn update_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, _schema: Option<&str>, max_blob_size: u64) -> Result { + update_record(params, table, pk_col, pk_val, col_name, new_val, max_blob_size).await + } + + async fn delete_record(&self, params: &crate::models::ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>) -> Result { + delete_record(params, table, pk_col, pk_val).await + } + + async fn save_blob_to_file(&self, params: &crate::models::ConnectionParams, table: &str, col_name: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>, file_path: &str) -> Result<(), String> { + save_blob_column_to_file(params, table, col_name, pk_col, pk_val, file_path).await + } + + async fn fetch_blob_as_data_url(&self, params: &crate::models::ConnectionParams, table: &str, col_name: &str, pk_col: &str, pk_val: serde_json::Value, _schema: Option<&str>) -> Result { + fetch_blob_column_as_data_url(params, table, col_name, pk_col, pk_val).await + } + + async fn get_create_table_sql( + &self, + table_name: &str, + columns: Vec, + _schema: Option<&str>, + ) -> Result, String> { + let mut col_defs = Vec::new(); + let mut pk_cols = Vec::new(); + let single_pk = columns.iter().filter(|c| c.is_pk).count() == 1; + for col in &columns { + let mut def = format!("\"{}\" {}", col.name.replace('"', "\"\""), col.data_type); + if col.is_pk && single_pk { + def.push_str(" PRIMARY KEY"); + if col.is_auto_increment { + def.push_str(" AUTOINCREMENT"); + } + } + if !col.is_nullable && !(col.is_pk && single_pk) { + def.push_str(" NOT NULL"); + } + if let Some(default) = &col.default_value { + def.push_str(&format!(" DEFAULT {}", default)); + } + col_defs.push(def); + if col.is_pk && !single_pk { + pk_cols.push(format!("\"{}\"", col.name.replace('"', "\"\""))); + } + } + if !pk_cols.is_empty() { + col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); + } + Ok(vec![format!( + "CREATE TABLE \"{}\" (\n {}\n)", + table_name.replace('"', "\"\""), + col_defs.join(",\n ") + )]) + } + + async fn get_add_column_sql( + &self, + table: &str, + column: crate::models::ColumnDefinition, + _schema: Option<&str>, + ) -> Result, String> { + let mut def = format!( + "ALTER TABLE \"{}\" ADD COLUMN \"{}\" {}", + table.replace('"', "\"\""), + column.name.replace('"', "\"\""), + column.data_type + ); + if !column.is_nullable { + def.push_str(" NOT NULL"); + } + if let Some(default) = &column.default_value { + def.push_str(&format!(" DEFAULT {}", default)); + } + Ok(vec![def]) + } + + async fn get_alter_column_sql( + &self, + table: &str, + old_column: crate::models::ColumnDefinition, + new_column: crate::models::ColumnDefinition, + _schema: Option<&str>, + ) -> Result, String> { + if old_column.name != new_column.name { + return Ok(vec![format!( + "ALTER TABLE \"{}\" RENAME COLUMN \"{}\" TO \"{}\"", + table.replace('"', "\"\""), + old_column.name.replace('"', "\"\""), + new_column.name.replace('"', "\"\"") + )]); + } + Err("SQLite only supports renaming columns. Other column modifications require recreating the table.".into()) + } + + async fn get_create_index_sql( + &self, + table: &str, + index_name: &str, + columns: Vec, + is_unique: bool, + _schema: Option<&str>, + ) -> Result, String> { + let unique = if is_unique { "UNIQUE " } else { "" }; + let cols: Vec = columns + .iter() + .map(|c| format!("\"{}\"", c.replace('"', "\"\""))) + .collect(); + Ok(vec![format!( + "CREATE {}INDEX \"{}\" ON \"{}\" ({})", + unique, + index_name.replace('"', "\"\""), + table.replace('"', "\"\""), + cols.join(", ") + )]) + } + + async fn get_create_foreign_key_sql( + &self, + _table: &str, + _fk_name: &str, + _column: &str, + _ref_table: &str, + _ref_column: &str, + _on_delete: Option<&str>, + _on_update: Option<&str>, + _schema: Option<&str>, + ) -> Result, String> { + Err("SQLite does not support adding foreign keys to existing tables. Foreign keys must be defined at table creation time.".into()) + } + + async fn drop_index( + &self, + params: &crate::models::ConnectionParams, + _table: &str, + index_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + let sql = format!("DROP INDEX \"{}\"", index_name.replace('"', "\"\"")); + execute_query(params, &sql, None, 1).await?; + Ok(()) + } + + async fn drop_foreign_key( + &self, + _params: &crate::models::ConnectionParams, + _table: &str, + _fk_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + Err("SQLite does not support dropping foreign keys".into()) + } + + async fn get_all_columns_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + let tables = get_tables(params).await?; + let names: Vec = tables.into_iter().map(|t| t.name).collect(); + get_all_columns_batch(params, &names).await + } + + async fn get_all_foreign_keys_batch(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result>, String> { + let tables = get_tables(params).await?; + let names: Vec = tables.into_iter().map(|t| t.name).collect(); + get_all_foreign_keys_batch(params, &names).await + } + + async fn get_schema_snapshot(&self, params: &crate::models::ConnectionParams, _schema: Option<&str>) -> Result, String> { + let tables = get_tables(params).await?; + let names: Vec = tables.iter().map(|t| t.name.clone()).collect(); + let mut columns_map = get_all_columns_batch(params, &names).await?; + let mut fks_map = get_all_foreign_keys_batch(params, &names).await?; + Ok(tables.into_iter().map(|t| crate::models::TableSchema { + name: t.name.clone(), + columns: columns_map.remove(&t.name).unwrap_or_default(), + foreign_keys: fks_map.remove(&t.name).unwrap_or_default(), + }).collect()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c36a3e..6997aea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,10 +19,13 @@ pub mod ssh_tunnel; pub mod theme_commands; pub mod theme_models; pub mod updater; +pub mod plugins; pub mod drivers { pub mod common; + pub mod driver_trait; pub mod mysql; pub mod postgres; + pub mod registry; pub mod sqlite; } @@ -126,6 +129,16 @@ pub fn run() { .manage(dump_commands::DumpCancellationState::default()) .manage(log_buffer) .setup(move |app| { + // Register built-in drivers + tauri::async_runtime::block_on(async { + drivers::registry::register_driver(drivers::mysql::MysqlDriver::new()).await; + drivers::registry::register_driver(drivers::postgres::PostgresDriver::new()).await; + drivers::registry::register_driver(drivers::sqlite::SqliteDriver::new()).await; + + // Load external plugins + crate::plugins::manager::load_plugins().await; + }); + // Open devtools automatically in debug mode if args.debug { if let Some(window) = app.get_webview_window("main") { @@ -139,6 +152,7 @@ pub fn run() { is_debug_mode, open_devtools, close_devtools, + commands::get_registered_drivers, commands::test_connection, commands::list_databases, commands::save_connection, @@ -207,6 +221,14 @@ pub fn run() { ai::explain_ai_query, ai::get_ai_models, commands::get_schema_snapshot, + // DDL generation + commands::get_create_table_sql, + commands::get_add_column_sql, + commands::get_alter_column_sql, + commands::get_create_index_sql, + commands::get_create_foreign_key_sql, + commands::drop_index_action, + commands::drop_foreign_key_action, // Routines commands::get_routines, commands::get_routine_parameters, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 8ab1547..d853cf4 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -92,12 +92,12 @@ pub struct TestConnectionRequest { pub connection_id: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct TableInfo { pub name: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct TableColumn { pub name: String, pub data_type: String, @@ -108,7 +108,7 @@ pub struct TableColumn { pub default_value: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ForeignKey { pub name: String, pub column_name: String, @@ -118,7 +118,7 @@ pub struct ForeignKey { pub on_update: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Index { pub name: String, pub column_name: String, @@ -134,7 +134,7 @@ pub struct Pagination { pub total_rows: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct QueryResult { pub columns: Vec, pub rows: Vec>, @@ -144,21 +144,21 @@ pub struct QueryResult { pub pagination: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct TableSchema { pub name: String, pub columns: Vec, pub foreign_keys: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct RoutineInfo { pub name: String, pub routine_type: String, // "PROCEDURE" | "FUNCTION" pub definition: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct RoutineParameter { pub name: String, pub data_type: String, @@ -166,13 +166,23 @@ pub struct RoutineParameter { pub ordinal_position: i32, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ViewInfo { pub name: String, pub definition: Option, } -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ColumnDefinition { + pub name: String, + pub data_type: String, + pub is_nullable: bool, + pub is_pk: bool, + pub is_auto_increment: bool, + pub default_value: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct DataTypeInfo { pub name: String, pub category: String, // "numeric", "string", "date", "binary", "json", "spatial", "other" @@ -181,7 +191,7 @@ pub struct DataTypeInfo { pub default_length: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct DataTypeRegistry { pub driver: String, pub types: Vec, diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs new file mode 100644 index 0000000..57231bf --- /dev/null +++ b/src-tauri/src/plugins/driver.rs @@ -0,0 +1,298 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::{mpsc, oneshot}; + +use crate::drivers::driver_trait::{DatabaseDriver, PluginManifest}; +use crate::models::{ + ColumnDefinition, ConnectionParams, DataTypeInfo, ForeignKey, Index, QueryResult, RoutineInfo, + RoutineParameter, TableColumn, TableInfo, TableSchema, ViewInfo, +}; +use crate::plugins::rpc::{JsonRpcRequest, JsonRpcResponse}; + +struct PluginProcess { + sender: mpsc::Sender<(JsonRpcRequest, oneshot::Sender>)>, + next_id: AtomicU64, +} + +impl PluginProcess { + fn new(executable_path: PathBuf) -> Self { + let (tx, mut rx) = mpsc::channel::<(JsonRpcRequest, oneshot::Sender>)>(100); + + tokio::spawn(async move { + let mut child = Command::new(&executable_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("Failed to start plugin process"); + + let mut stdin = child.stdin.take().expect("Failed to open stdin"); + let stdout = child.stdout.take().expect("Failed to open stdout"); + let mut reader = BufReader::new(stdout); + + let mut pending_requests: HashMap>> = HashMap::new(); + let mut line_buf = String::new(); + + loop { + tokio::select! { + Some((req, resp_tx)) = rx.recv() => { + let id = req.id; + pending_requests.insert(id, resp_tx); + + let mut req_str = serde_json::to_string(&req).unwrap(); + req_str.push('\n'); + + if let Err(e) = stdin.write_all(req_str.as_bytes()).await { + log::error!("Failed to write to plugin stdin: {}", e); + if let Some(tx) = pending_requests.remove(&id) { + let _ = tx.send(Err(format!("Plugin communication error: {}", e))); + } + } + } + line_result = reader.read_line(&mut line_buf) => { + match line_result { + Ok(0) => { + log::error!("Plugin process exited unexpectedly"); + break; + } + Ok(_) => { + match serde_json::from_str::(&line_buf) { + Ok(JsonRpcResponse::Success { result, id, .. }) => { + if let Some(tx) = pending_requests.remove(&id) { + let _ = tx.send(Ok(result)); + } + } + Ok(JsonRpcResponse::Error { error, id, .. }) => { + if let Some(tx) = pending_requests.remove(&id) { + let _ = tx.send(Err(error.message)); + } + } + Err(e) => { + log::error!("Failed to parse plugin response: {}", e); + } + } + line_buf.clear(); + } + Err(e) => { + log::error!("Failed to read from plugin stdout: {}", e); + break; + } + } + } + } + } + }); + + Self { + sender: tx, + next_id: AtomicU64::new(1), + } + } + + async fn call(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params, + id, + }; + + let (tx, rx) = oneshot::channel(); + self.sender.send((req, tx)).await.map_err(|_| "Plugin process channel closed".to_string())?; + + rx.await.map_err(|_| "Plugin process did not respond".to_string())? + } +} + +pub struct RpcDriver { + manifest: PluginManifest, + process: Arc, + data_types: Vec, +} + +impl RpcDriver { + pub fn new(manifest: PluginManifest, executable_path: PathBuf, data_types: Vec) -> Self { + Self { + manifest, + process: Arc::new(PluginProcess::new(executable_path)), + data_types, + } + } +} + +#[async_trait] +impl DatabaseDriver for RpcDriver { + fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + fn get_data_types(&self) -> Vec { + self.data_types.clone() + } + + fn build_connection_url(&self, _params: &ConnectionParams) -> Result { + // Plugin drivers manage their own connections — no URL needed. + Ok(format!("{}://...", self.manifest.id)) + } + + async fn test_connection(&self, params: &ConnectionParams) -> Result<(), String> { + // Delegate to the plugin process via RPC instead of using sqlx + let res = self.process.call("test_connection", json!({ "params": params })).await?; + // If the plugin returns a success response (even null/true), connection is ok + let _ = res; + Ok(()) + } + + async fn get_databases(&self, params: &ConnectionParams) -> Result, String> { + let res = self.process.call("get_databases", json!({ "params": params })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_schemas(&self, params: &ConnectionParams) -> Result, String> { + let res = self.process.call("get_schemas", json!({ "params": params })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_tables(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_tables", json!({ "params": params, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_columns(&self, params: &ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_columns", json!({ "params": params, "table": table, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_foreign_keys(&self, params: &ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_foreign_keys", json!({ "params": params, "table": table, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_indexes(&self, params: &ConnectionParams, table: &str, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_indexes", json!({ "params": params, "table": table, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_views(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_views", json!({ "params": params, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_view_definition(&self, params: &ConnectionParams, view_name: &str, schema: Option<&str>) -> Result { + let res = self.process.call("get_view_definition", json!({ "params": params, "view_name": view_name, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_view_columns(&self, params: &ConnectionParams, view_name: &str, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_view_columns", json!({ "params": params, "view_name": view_name, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn create_view(&self, params: &ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String> { + let res = self.process.call("create_view", json!({ "params": params, "view_name": view_name, "definition": definition, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn alter_view(&self, params: &ConnectionParams, view_name: &str, definition: &str, schema: Option<&str>) -> Result<(), String> { + let res = self.process.call("alter_view", json!({ "params": params, "view_name": view_name, "definition": definition, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn drop_view(&self, params: &ConnectionParams, view_name: &str, schema: Option<&str>) -> Result<(), String> { + let res = self.process.call("drop_view", json!({ "params": params, "view_name": view_name, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_routines(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_routines", json!({ "params": params, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_routine_parameters(&self, params: &ConnectionParams, routine_name: &str, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_routine_parameters", json!({ "params": params, "routine_name": routine_name, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_routine_definition(&self, params: &ConnectionParams, routine_name: &str, routine_type: &str, schema: Option<&str>) -> Result { + let res = self.process.call("get_routine_definition", json!({ "params": params, "routine_name": routine_name, "routine_type": routine_type, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn execute_query(&self, params: &ConnectionParams, query: &str, limit: Option, page: u32, schema: Option<&str>) -> Result { + let res = self.process.call("execute_query", json!({ "params": params, "query": query, "limit": limit, "page": page, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn insert_record(&self, params: &ConnectionParams, table: &str, data: HashMap, schema: Option<&str>, max_blob_size: u64) -> Result { + let res = self.process.call("insert_record", json!({ "params": params, "table": table, "data": data, "schema": schema, "max_blob_size": max_blob_size })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn update_record(&self, params: &ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, col_name: &str, new_val: serde_json::Value, schema: Option<&str>, max_blob_size: u64) -> Result { + let res = self.process.call("update_record", json!({ "params": params, "table": table, "pk_col": pk_col, "pk_val": pk_val, "col_name": col_name, "new_val": new_val, "schema": schema, "max_blob_size": max_blob_size })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn delete_record(&self, params: &ConnectionParams, table: &str, pk_col: &str, pk_val: serde_json::Value, schema: Option<&str>) -> Result { + let res = self.process.call("delete_record", json!({ "params": params, "table": table, "pk_col": pk_col, "pk_val": pk_val, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_create_table_sql(&self, table_name: &str, columns: Vec, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_create_table_sql", json!({ "table_name": table_name, "columns": columns, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_add_column_sql(&self, table: &str, column: ColumnDefinition, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_add_column_sql", json!({ "table": table, "column": column, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_alter_column_sql(&self, table: &str, old_column: ColumnDefinition, new_column: ColumnDefinition, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_alter_column_sql", json!({ "table": table, "old_column": old_column, "new_column": new_column, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_create_index_sql(&self, table: &str, index_name: &str, columns: Vec, is_unique: bool, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_create_index_sql", json!({ "table": table, "index_name": index_name, "columns": columns, "is_unique": is_unique, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_create_foreign_key_sql(&self, table: &str, fk_name: &str, column: &str, ref_table: &str, ref_column: &str, on_delete: Option<&str>, on_update: Option<&str>, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_create_foreign_key_sql", json!({ "table": table, "fk_name": fk_name, "column": column, "ref_table": ref_table, "ref_column": ref_column, "on_delete": on_delete, "on_update": on_update, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn drop_index(&self, params: &ConnectionParams, table: &str, index_name: &str, schema: Option<&str>) -> Result<(), String> { + self.process.call("drop_index", json!({ "params": params, "table": table, "index_name": index_name, "schema": schema })).await?; + Ok(()) + } + + async fn drop_foreign_key(&self, params: &ConnectionParams, table: &str, fk_name: &str, schema: Option<&str>) -> Result<(), String> { + self.process.call("drop_foreign_key", json!({ "params": params, "table": table, "fk_name": fk_name, "schema": schema })).await?; + Ok(()) + } + + async fn get_schema_snapshot(&self, params: &ConnectionParams, schema: Option<&str>) -> Result, String> { + let res = self.process.call("get_schema_snapshot", json!({ "params": params, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_all_columns_batch(&self, params: &ConnectionParams, schema: Option<&str>) -> Result>, String> { + let res = self.process.call("get_all_columns_batch", json!({ "params": params, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } + + async fn get_all_foreign_keys_batch(&self, params: &ConnectionParams, schema: Option<&str>) -> Result>, String> { + let res = self.process.call("get_all_foreign_keys_batch", json!({ "params": params, "schema": schema })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) + } +} diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs new file mode 100644 index 0000000..c1f946a --- /dev/null +++ b/src-tauri/src/plugins/manager.rs @@ -0,0 +1,93 @@ +use std::fs; +use std::path::Path; + +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +use crate::drivers::driver_trait::{DriverCapabilities, PluginManifest}; +use crate::models::DataTypeInfo; +use crate::plugins::driver::RpcDriver; + +#[derive(Serialize, Deserialize)] +struct ConfigManifest { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub default_port: Option, + pub capabilities: DriverCapabilities, + pub data_types: Vec, + pub executable: String, +} + +pub async fn load_plugins() { + let proj_dirs = match ProjectDirs::from("com", "debba", "tabularis") { + Some(d) => d, + None => return, + }; + + let plugins_dir = proj_dirs.data_dir().join("plugins"); + + if !plugins_dir.exists() { + if let Err(e) = fs::create_dir_all(&plugins_dir) { + log::error!("Failed to create plugins directory: {}", e); + return; + } + } + + let entries = match fs::read_dir(&plugins_dir) { + Ok(e) => e, + Err(e) => { + log::error!("Failed to read plugins directory: {}", e); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + load_plugin_from_dir(&path).await; + } + } +} + +async fn load_plugin_from_dir(path: &Path) { + let manifest_path = path.join("manifest.json"); + if !manifest_path.exists() { + return; + } + + let manifest_str = match fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(e) => { + log::error!("Failed to read plugin manifest {:?}: {}", manifest_path, e); + return; + } + }; + + let config: ConfigManifest = match serde_json::from_str(&manifest_str) { + Ok(c) => c, + Err(e) => { + log::error!("Failed to parse plugin manifest {:?}: {}", manifest_path, e); + return; + } + }; + + let exec_path = path.join(&config.executable); + if !exec_path.exists() { + log::error!("Plugin executable not found: {:?}", exec_path); + return; + } + + let manifest = PluginManifest { + id: config.id, + name: config.name, + version: config.version, + description: config.description, + default_port: config.default_port, + capabilities: config.capabilities, + }; + + let driver = RpcDriver::new(manifest, exec_path, config.data_types); + crate::drivers::registry::register_driver(driver).await; +} diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs new file mode 100644 index 0000000..6475d61 --- /dev/null +++ b/src-tauri/src/plugins/mod.rs @@ -0,0 +1,3 @@ +pub mod rpc; +pub mod manager; +pub mod driver; diff --git a/src-tauri/src/plugins/rpc.rs b/src-tauri/src/plugins/rpc.rs new file mode 100644 index 0000000..7ed3ece --- /dev/null +++ b/src-tauri/src/plugins/rpc.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Serialize, Deserialize, Debug)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub method: String, + pub params: Value, + pub id: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(untagged)] +pub enum JsonRpcResponse { + Success { + jsonrpc: String, + result: Value, + id: u64, + }, + Error { + jsonrpc: String, + error: JsonRpcError, + id: u64, + }, +} diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index bc4c4cc..d6c814a 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { quoteIdentifier, quoteTableRef } from "../../utils/identifiers"; +import { quoteTableRef } from "../../utils/identifiers"; import { invoke } from "@tauri-apps/api/core"; import { Database, @@ -636,17 +636,17 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse }: Explo onAddIndex={(t_name) => setCreateIndexModal({ isOpen: true, tableName: t_name }) } - onDropIndex={async (_t_name, name) => { + onDropIndex={async (t_name, name) => { if ( await ask( t("sidebar.deleteIndexConfirm", { name }), { title: t("sidebar.deleteIndex"), kind: "warning" }, ) ) { - const q = `DROP INDEX ${quoteTableRef(name, activeDriver, schemaName)}`; - await invoke("execute_query", { + await invoke("drop_index_action", { connectionId: activeConnectionId, - query: q, + table: t_name, + indexName: name, ...(schemaName ? { schema: schemaName } : {}), }).catch(console.error); setSchemaVersion((v) => v + 1); @@ -662,10 +662,10 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse }: Explo { title: t("sidebar.deleteFk"), kind: "warning" }, ) ) { - const q = `ALTER TABLE ${quoteTableRef(t_name, activeDriver, schemaName)} DROP CONSTRAINT ${quoteIdentifier(name, activeDriver)}`; - await invoke("execute_query", { + await invoke("drop_foreign_key_action", { connectionId: activeConnectionId, - query: q, + table: t_name, + fkName: name, ...(schemaName ? { schema: schemaName } : {}), }).catch(console.error); setSchemaVersion((v) => v + 1); @@ -754,13 +754,10 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse }: Explo { title: t("sidebar.deleteIndex"), kind: "warning" }, ) ) { - const q = - activeDriver === "mysql" || activeDriver === "mariadb" - ? `DROP INDEX ${quoteIdentifier(name, activeDriver)} ON ${quoteTableRef(t_name, activeDriver)}` - : `DROP INDEX ${quoteIdentifier(name, activeDriver)}`; - await invoke("execute_query", { + await invoke("drop_index_action", { connectionId: activeConnectionId, - query: q, + table: t_name, + indexName: name, }).catch(console.error); setSchemaVersion((v) => v + 1); } @@ -775,17 +772,10 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse }: Explo { title: t("sidebar.deleteFk"), kind: "warning" }, ) ) { - if (activeDriver === "sqlite") { - await message(t("sidebar.sqliteFkError"), { kind: "error" }); - return; - } - const q = - activeDriver === "mysql" || activeDriver === "mariadb" - ? `ALTER TABLE ${quoteTableRef(t_name, activeDriver)} DROP FOREIGN KEY ${quoteIdentifier(name, activeDriver)}` - : `ALTER TABLE ${quoteTableRef(t_name, activeDriver)} DROP CONSTRAINT ${quoteIdentifier(name, activeDriver)}`; - await invoke("execute_query", { + await invoke("drop_foreign_key_action", { connectionId: activeConnectionId, - query: q, + table: t_name, + fkName: name, }).catch(console.error); setSchemaVersion((v) => v + 1); } @@ -1047,13 +1037,10 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse }: Explo ) ) { try { - const q = - activeDriver === "mysql" || activeDriver === "mariadb" - ? `DROP INDEX ${quoteIdentifier(contextMenu.id, activeDriver)} ON ${quoteTableRef(t_name, activeDriver, ctxSchema)}` - : `DROP INDEX ${quoteTableRef(contextMenu.id, activeDriver, ctxSchema)}`; - await invoke("execute_query", { + await invoke("drop_index_action", { connectionId: activeConnectionId, - query: q, + table: t_name, + indexName: contextMenu.id, ...(ctxSchema ? { schema: ctxSchema } : {}), }); setSchemaVersion((v) => v + 1); @@ -1089,19 +1076,17 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse }: Explo { title: t("sidebar.deleteFk"), kind: "warning" }, ) ) { - if (activeDriver === "sqlite") { - await message(t("sidebar.sqliteFkError"), { kind: "error" }); - return; + try { + await invoke("drop_foreign_key_action", { + connectionId: activeConnectionId, + table: t_name, + fkName: contextMenu.id, + ...(ctxSchema ? { schema: ctxSchema } : {}), + }); + setSchemaVersion((v) => v + 1); + } catch (e) { + await message(String(e), { kind: "error" }); } - const q = - activeDriver === "mysql" || activeDriver === "mariadb" - ? `ALTER TABLE ${quoteTableRef(t_name, activeDriver, ctxSchema)} DROP FOREIGN KEY ${quoteIdentifier(contextMenu.id, activeDriver)}` - : `ALTER TABLE ${quoteTableRef(t_name, activeDriver, ctxSchema)} DROP CONSTRAINT ${quoteIdentifier(contextMenu.id, activeDriver)}`; - await invoke("execute_query", { - connectionId: activeConnectionId, - query: q, - ...(ctxSchema ? { schema: ctxSchema } : {}), - }).catch(console.error); } } }, @@ -1314,7 +1299,6 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse }: Explo onSuccess={() => setSchemaVersion((v) => v + 1)} connectionId={activeConnectionId} tableName={createIndexModal.tableName} - driver={activeDriver || "sqlite"} /> )} diff --git a/src/components/modals/CreateForeignKeyModal.tsx b/src/components/modals/CreateForeignKeyModal.tsx index d0da24f..007929f 100644 --- a/src/components/modals/CreateForeignKeyModal.tsx +++ b/src/components/modals/CreateForeignKeyModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { X, Save, Loader2, AlertTriangle } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; @@ -95,31 +95,60 @@ export const CreateForeignKeyModal = ({ } }, [localColumn, refTable, tableName]); - const sqlPreview = useMemo(() => { - if (!fkName || !localColumn || !refTable || !refColumn) return '-- ' + t('createFk.sqlPreview'); - - const q = (driver === 'mysql' || driver === 'mariadb') ? '`' : '"'; - - // ALTER TABLE child ADD CONSTRAINT fk_name FOREIGN KEY (col) REFERENCES parent (col) ON DELETE ... ON UPDATE ... - return `ALTER TABLE ${q}${tableName}${q} ADD CONSTRAINT ${q}${fkName}${q} FOREIGN KEY (${q}${localColumn}${q}) REFERENCES ${q}${refTable}${q} (${q}${refColumn}${q}) ON DELETE ${onDelete} ON UPDATE ${onUpdate};`; - }, [fkName, localColumn, refTable, refColumn, onDelete, onUpdate, tableName, driver, t]); + const [sqlPreview, setSqlPreview] = useState('-- ...'); + + const generatePreview = useCallback(async () => { + if (!fkName || !localColumn || !refTable || !refColumn) { + setSqlPreview('-- ' + t('createFk.sqlPreview')); + return; + } + try { + const stmts = await invoke('get_create_foreign_key_sql', { + connectionId, + table: tableName, + fkName, + column: localColumn, + refTable, + refColumn, + onDelete, + onUpdate, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + setSqlPreview(stmts.map(s => s + ';').join('\n')); + } catch (e) { + setSqlPreview('-- ' + String(e)); + } + }, [fkName, localColumn, refTable, refColumn, onDelete, onUpdate, connectionId, tableName, activeSchema, t]); + + useEffect(() => { + const timer = setTimeout(generatePreview, 300); + return () => clearTimeout(timer); + }, [generatePreview]); const handleCreate = async () => { if (!fkName.trim()) { setError(t('createFk.nameRequired')); return; } - - if (driver === 'sqlite') { - setError(t('sidebar.sqliteFkError')); - return; - } setLoading(true); setError(''); try { - await invoke('execute_query', { + const stmts = await invoke('get_create_foreign_key_sql', { connectionId, - query: sqlPreview, + table: tableName, + fkName, + column: localColumn, + refTable, + refColumn, + onDelete, + onUpdate, ...(activeSchema ? { schema: activeSchema } : {}), }); + for (const sql of stmts) { + await invoke('execute_query', { + connectionId, + query: sql, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + } onSuccess(); onClose(); } catch (e) { diff --git a/src/components/modals/CreateIndexModal.tsx b/src/components/modals/CreateIndexModal.tsx index 14b269a..7cc9a6b 100644 --- a/src/components/modals/CreateIndexModal.tsx +++ b/src/components/modals/CreateIndexModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { X, Save, Loader2 } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; @@ -11,7 +11,6 @@ interface CreateIndexModalProps { onSuccess: () => void; connectionId: string; tableName: string; - driver: string; } interface TableColumn { @@ -24,7 +23,6 @@ export const CreateIndexModal = ({ onSuccess, connectionId, tableName, - driver }: CreateIndexModalProps) => { const { t } = useTranslation(); const { activeSchema } = useDatabase(); @@ -59,33 +57,55 @@ export const CreateIndexModal = ({ } }; - const sqlPreview = useMemo(() => { - if (!indexName || selectedColumns.length === 0) return '-- ' + t('createIndex.nameRequired'); - - const q = (driver === 'mysql' || driver === 'mariadb') ? '`' : '"'; - const cols = selectedColumns.map(c => `${q}${c}${q}`).join(', '); - - if (driver === 'mysql' || driver === 'mariadb') { - // MySQL: CREATE [UNIQUE] INDEX index_name ON table (cols) - return `CREATE ${isUnique ? 'UNIQUE ' : ''}INDEX ${q}${indexName}${q} ON ${q}${tableName}${q} (${cols});`; - } else { - // Postgres/SQLite: CREATE [UNIQUE] INDEX [CONCURRENTLY?] index_name ON table (cols) - return `CREATE ${isUnique ? 'UNIQUE ' : ''}INDEX ${q}${indexName}${q} ON ${q}${tableName}${q} (${cols});`; - } - }, [indexName, isUnique, selectedColumns, driver, tableName, t]); + const [sqlPreview, setSqlPreview] = useState('-- ...'); + + const generatePreview = useCallback(async () => { + if (!indexName || selectedColumns.length === 0) { + setSqlPreview('-- ' + t('createIndex.nameRequired')); + return; + } + try { + const stmts = await invoke('get_create_index_sql', { + connectionId, + table: tableName, + indexName, + columns: selectedColumns, + isUnique, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + setSqlPreview(stmts.map(s => s + ';').join('\n')); + } catch (e) { + setSqlPreview('-- ' + String(e)); + } + }, [indexName, isUnique, selectedColumns, connectionId, tableName, activeSchema, t]); + + useEffect(() => { + const timer = setTimeout(generatePreview, 300); + return () => clearTimeout(timer); + }, [generatePreview]); const handleCreate = async () => { if (!indexName.trim()) { setError(t('createIndex.nameRequired')); return; } if (selectedColumns.length === 0) { setError(t('createIndex.colRequired')); return; } - + setLoading(true); setError(''); try { - await invoke('execute_query', { + const stmts = await invoke('get_create_index_sql', { connectionId, - query: sqlPreview, + table: tableName, + indexName, + columns: selectedColumns, + isUnique, ...(activeSchema ? { schema: activeSchema } : {}), }); + for (const sql of stmts) { + await invoke('execute_query', { + connectionId, + query: sql, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + } onSuccess(); onClose(); } catch (e) { diff --git a/src/components/modals/CreateTableModal.tsx b/src/components/modals/CreateTableModal.tsx index 68b905f..f67fe8f 100644 --- a/src/components/modals/CreateTableModal.tsx +++ b/src/components/modals/CreateTableModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { X, Plus, Trash2, Save, Code, Loader2 } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; @@ -44,67 +44,44 @@ export const CreateTableModal = ({ isOpen, onClose, onSuccess }: CreateTableModa [dataTypes], ); - const sqlPreview = useMemo(() => { - if (!tableName.trim()) return '-- ' + t('createTable.nameRequired'); - if (columns.length === 0) return '-- ' + t('createTable.colRequired'); + const [sqlPreview, setSqlPreview] = useState('-- ...'); - let sql = `CREATE TABLE ${currentDriver === 'postgres' ? `"${tableName}"` : `\`${tableName}\``} (\n`; - - const colDefs = columns.map(col => { - let def = ` ${currentDriver === 'postgres' ? `"${col.name}"` : `\`${col.name}\``}`; - - // Type mapping / logic - let type = col.type; - - // Driver specific adjustments - if (currentDriver === 'postgres') { - if (col.isAutoInc && (type === 'INTEGER' || type === 'BIGINT')) { - type = type === 'BIGINT' ? 'BIGSERIAL' : 'SERIAL'; - } - } else if (currentDriver === 'sqlite') { - if (col.isPk && col.isAutoInc) { - type = 'INTEGER'; // SQLite requirement for auto_increment - } - } - - // Length - if (col.length && (type.includes('CHAR') || type === 'VARCHAR')) { - type += `(${col.length})`; - } - - def += ` ${type}`; - - // Constraints - if (currentDriver === 'sqlite' && col.isPk) { - def += ' PRIMARY KEY'; - if (col.isAutoInc) def += ' AUTOINCREMENT'; - } else { - if (!col.isNullable) def += ' NOT NULL'; - - if (currentDriver === 'mysql' && col.isAutoInc) def += ' AUTO_INCREMENT'; - - if (col.defaultValue) { - const isNum = !isNaN(Number(col.defaultValue)); - def += ` DEFAULT ${isNum ? col.defaultValue : `'${col.defaultValue}'`}`; - } - } - - return def; - }); + const generatePreview = useCallback(async () => { + if (!tableName.trim()) { + setSqlPreview('-- ' + t('createTable.nameRequired')); + return; + } + if (columns.length === 0) { + setSqlPreview('-- ' + t('createTable.colRequired')); + return; + } - sql += colDefs.join(',\n'); + try { + const colDefs = columns.map(col => ({ + name: col.name, + data_type: col.length ? `${col.type}(${col.length})` : col.type, + is_nullable: col.isNullable, + is_pk: col.isPk, + is_auto_increment: col.isAutoInc, + default_value: col.defaultValue || null, + })); - // Primary Keys (Non-SQLite or Composite or Standard) - if (currentDriver !== 'sqlite') { - const pks = columns.filter(c => c.isPk).map(c => currentDriver === 'postgres' ? `"${c.name}"` : `\`${c.name}\``); - if (pks.length > 0) { - sql += `,\n PRIMARY KEY (${pks.join(', ')})`; - } + const stmts = await invoke('get_create_table_sql', { + connectionId: activeConnectionId, + tableName, + columns: colDefs, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + setSqlPreview(stmts.map(s => s + ';').join('\n')); + } catch (e) { + setSqlPreview('-- ' + String(e)); } + }, [tableName, columns, activeConnectionId, activeSchema, t]); - sql += '\n);'; - return sql; - }, [tableName, columns, currentDriver, t]); + useEffect(() => { + const timer = setTimeout(generatePreview, 300); + return () => clearTimeout(timer); + }, [generatePreview]); const handleAddColumn = () => { setColumns([...columns, { @@ -139,11 +116,30 @@ export const CreateTableModal = ({ isOpen, onClose, onSuccess }: CreateTableModa setError(''); try { - await invoke('execute_query', { + const colDefs = columns.map(col => ({ + name: col.name, + data_type: col.length ? `${col.type}(${col.length})` : col.type, + is_nullable: col.isNullable, + is_pk: col.isPk, + is_auto_increment: col.isAutoInc, + default_value: col.defaultValue || null, + })); + + const stmts = await invoke('get_create_table_sql', { + connectionId: activeConnectionId, + tableName, + columns: colDefs, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + + for (const sql of stmts) { + await invoke('execute_query', { connectionId: activeConnectionId, - query: sqlPreview, + query: sql, ...(activeSchema ? { schema: activeSchema } : {}), - }); + }); + } + onSuccess(); onClose(); // Reset state diff --git a/src/components/modals/ModifyColumnModal.tsx b/src/components/modals/ModifyColumnModal.tsx index 67b9dd1..a5debd8 100644 --- a/src/components/modals/ModifyColumnModal.tsx +++ b/src/components/modals/ModifyColumnModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { X, Save, Loader2, AlertTriangle } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; @@ -33,6 +33,27 @@ interface ModifyColumnModalProps { } | null; } +interface ColumnDefinition { + name: string; + data_type: string; + is_nullable: boolean; + is_pk: boolean; + is_auto_increment: boolean; + default_value: string | null; +} + +function buildColumnDefinition(form: ColumnDef): ColumnDefinition { + const typeDef = `${form.type}${form.length ? `(${form.length})` : ""}`; + return { + name: form.name, + data_type: typeDef, + is_nullable: form.isNullable, + is_pk: form.isPk, + is_auto_increment: form.isAutoInc, + default_value: form.defaultValue || null, + }; +} + export const ModifyColumnModal = ({ isOpen, onClose, @@ -55,11 +76,11 @@ export const ModifyColumnModal = ({ // Parse initial type/length from column.data_type if possible // e.g. "varchar(255)" -> type="VARCHAR", length="255" const parseType = (fullType: string) => { - const match = fullType.match(/^([a-zA-Z0-9_]+)(?:\((.+)\))?$/); + const match = fullType.match(/^([a-zA-Z0-9_ ]+)(?:\((.+)\))?$/); if (match) { - return { type: match[1].toUpperCase(), length: match[2] || "" }; + return { type: match[1].toUpperCase().trim(), length: match[2] || "" }; } - return { type: fullType.toUpperCase(), length: "" }; + return { type: fullType.toUpperCase().trim(), length: "" }; }; const initial = useMemo(() => { @@ -70,7 +91,7 @@ export const ModifyColumnModal = ({ type, length, isNullable: column.is_nullable, - defaultValue: "", // We don't have this info easily from get_columns yet + defaultValue: "", isPk: column.is_pk || false, isAutoInc: column.is_auto_increment || false, }; @@ -89,127 +110,62 @@ export const ModifyColumnModal = ({ const [form, setForm] = useState(initial); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [sqlPreview, setSqlPreview] = useState("-- ..."); // Reset form when modal opens/changes useEffect(() => { setForm(initial); setError(""); + setSqlPreview("-- ..."); }, [initial, isOpen]); - const sqlPreview = useMemo(() => { - if (!form.name) return "-- " + t("modifyColumn.nameRequired"); - - const q = driver === "mysql" || driver === "mariadb" ? "`" : '"'; - const typeDef = `${form.type}${form.length ? `(${form.length})` : ""}`; - const nullableDef = form.isNullable - ? driver === "mysql" - ? "NULL" - : "" - : "NOT NULL"; // MySQL explicit NULL is ok, Postgres default is NULL - const defaultDef = form.defaultValue - ? `DEFAULT ${!isNaN(Number(form.defaultValue)) ? form.defaultValue : `'${form.defaultValue}'`}` - : ""; - - // Constraints logic - let constraintsDef = ""; - - // ADD COLUMN logic - if (!isEdit) { - if (driver === "mysql" || driver === "mariadb") { - if (form.isPk) constraintsDef += " PRIMARY KEY"; - if (form.isAutoInc) constraintsDef += " AUTO_INCREMENT"; - } else if (driver === "sqlite") { - if (form.isPk) { - constraintsDef += " PRIMARY KEY"; - if (form.isAutoInc) constraintsDef += " AUTOINCREMENT"; - } - } else if (driver === "postgres") { - if (form.isPk) constraintsDef += " PRIMARY KEY"; - // Postgres AUTO_INCREMENT is handled via type (SERIAL/BIGSERIAL) usually, or GENERATED. - // Logic below handles type change for SERIAL. - } - } - - // Type override for Postgres AutoInc - let finalType = typeDef; - if (driver === "postgres" && form.isAutoInc && !isEdit) { - if (form.type === "INTEGER") finalType = "SERIAL"; - if (form.type === "BIGINT") finalType = "BIGSERIAL"; + // Generate SQL preview via backend + const generatePreview = useCallback(async () => { + if (!form.name) { + setSqlPreview("-- " + t("modifyColumn.nameRequired")); + return; } - // Combine definitions - - if (!isEdit) { - // ADD COLUMN - // Note: SQLite ADD COLUMN does not support PRIMARY KEY or UNIQUE constraints usually (unless simple). - // Postgres ADD COLUMN allows PRIMARY KEY. - return `ALTER TABLE ${q}${tableName}${q} ADD COLUMN ${q}${form.name}${q} ${finalType} ${nullableDef} ${defaultDef}${constraintsDef};`; - } else { - // MODIFY COLUMN - if (driver === "mysql" || driver === "mariadb") { - // MySQL: ALTER TABLE t CHANGE old new def [constraints] - // Re-build full definition including constraints - let mysqlConstraints = ""; - if (form.isAutoInc) mysqlConstraints += " AUTO_INCREMENT"; - // Note: PRIMARY KEY in MySQL MODIFY/CHANGE is tricky if it already exists. - // Usually you use ADD PRIMARY KEY or DROP PRIMARY KEY. - // But if it's a single column PK change, sometimes it works? - // Safer to warn or just omit PK from CHANGE and let user manage keys separately? - // For now, let's omit PK in CHANGE unless we are sure. - // But AUTO_INCREMENT requires Key. - - // If name is same, use MODIFY COLUMN to avoid repetition and potential confusion - if (column?.name === form.name) { - return `ALTER TABLE ${q}${tableName}${q} MODIFY COLUMN ${q}${form.name}${q} ${finalType} ${nullableDef} ${defaultDef}${mysqlConstraints};`; - } else { - return `ALTER TABLE ${q}${tableName}${q} CHANGE ${q}${column?.name}${q} ${q}${form.name}${q} ${finalType} ${nullableDef} ${defaultDef}${mysqlConstraints};`; - } - } else if (driver === "postgres") { - // Postgres - const statements = []; - - // Rename - if (column?.name !== form.name) { - statements.push( - `ALTER TABLE ${q}${tableName}${q} RENAME COLUMN ${q}${column?.name}${q} TO ${q}${form.name}${q};`, - ); - } - - // Type - const { type: oldType } = parseType(column?.data_type || ""); - if ( - oldType !== form.type || - parseType(column?.data_type || "").length !== form.length - ) { - statements.push( - `ALTER TABLE ${q}${tableName}${q} ALTER COLUMN ${q}${form.name}${q} TYPE ${finalType} USING ${q}${form.name}${q}::${form.type};`, - ); - } - - // Nullable - if (column?.is_nullable !== form.isNullable) { - statements.push( - `ALTER TABLE ${q}${tableName}${q} ALTER COLUMN ${q}${form.name}${q} ${form.isNullable ? "DROP" : "SET"} NOT NULL;`, - ); - } - - // Default - if (form.defaultValue) { - statements.push( - `ALTER TABLE ${q}${tableName}${q} ALTER COLUMN ${q}${form.name}${q} SET ${defaultDef};`, - ); - } - - // PK / AutoInc not supported well in MODIFY for Postgres here (requires sequence manipulation / constraint management) - - if (statements.length === 0) return "-- " + t("modifyColumn.noChanges"); - return statements.join("\n"); - } else if (driver === "sqlite") { - return "-- SQLite modification is limited. Rename only supported via RENAME COLUMN.\n-- Full modification requires table recreation."; + try { + let stmts: string[]; + if (isEdit) { + const oldCol = buildColumnDefinition({ + name: column!.name, + type: parseType(column!.data_type).type, + length: parseType(column!.data_type).length, + isNullable: column!.is_nullable, + defaultValue: "", + isPk: column!.is_pk, + isAutoInc: column!.is_auto_increment, + }); + const newCol = buildColumnDefinition(form); + stmts = await invoke("get_alter_column_sql", { + connectionId, + table: tableName, + oldColumn: oldCol, + newColumn: newCol, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + } else { + const col = buildColumnDefinition(form); + stmts = await invoke("get_add_column_sql", { + connectionId, + table: tableName, + column: col, + ...(activeSchema ? { schema: activeSchema } : {}), + }); } + setSqlPreview(stmts.map((s) => s + ";").join("\n")); + } catch (e) { + setSqlPreview("-- " + String(e)); } - return "-- " + t("modifyColumn.unsupported"); - }, [form, driver, isEdit, tableName, column, t]); + }, [form, isEdit, column, connectionId, tableName, activeSchema, t]); + + // Debounced preview generation + useEffect(() => { + const timer = setTimeout(generatePreview, 300); + return () => clearTimeout(timer); + }, [generatePreview]); const handleSubmit = async () => { if (!form.name.trim()) { @@ -220,42 +176,41 @@ export const ModifyColumnModal = ({ setError(""); try { - if (driver === "sqlite" && isEdit && form.name !== column?.name) { - // Special case for SQLite Rename - const q = '"'; - await invoke("execute_query", { + let stmts: string[]; + if (isEdit) { + const oldCol = buildColumnDefinition({ + name: column!.name, + type: parseType(column!.data_type).type, + length: parseType(column!.data_type).length, + isNullable: column!.is_nullable, + defaultValue: "", + isPk: column!.is_pk, + isAutoInc: column!.is_auto_increment, + }); + const newCol = buildColumnDefinition(form); + stmts = await invoke("get_alter_column_sql", { connectionId, - query: `ALTER TABLE ${q}${tableName}${q} RENAME COLUMN ${q}${column?.name}${q} TO ${q}${form.name}${q}`, + table: tableName, + oldColumn: oldCol, + newColumn: newCol, ...(activeSchema ? { schema: activeSchema } : {}), }); - } else if (driver === "sqlite" && isEdit) { - throw new Error(t("modifyColumn.sqliteWarn")); } else { - // Run the generated SQL - // Postgres might generate multiple statements joined by \n - // The backend execute_query might handle one at a time or script? - // Usually execute_query runs one statement. If we have multiple for Postgres, we need to split or run them sequentially. - // My backend currently executes strictly one statement usually unless split. - // Let's assume splitQueries logic on frontend or sequential calls. + const col = buildColumnDefinition(form); + stmts = await invoke("get_add_column_sql", { + connectionId, + table: tableName, + column: col, + ...(activeSchema ? { schema: activeSchema } : {}), + }); + } - if (driver === "postgres" && isEdit) { - const statements = sqlPreview - .split("\n") - .filter((s) => s.trim() && !s.startsWith("--")); - for (const sql of statements) { - await invoke("execute_query", { - connectionId, - query: sql, - ...(activeSchema ? { schema: activeSchema } : {}), - }); - } - } else { - await invoke("execute_query", { - connectionId, - query: sqlPreview, - ...(activeSchema ? { schema: activeSchema } : {}), - }); - } + for (const sql of stmts) { + await invoke("execute_query", { + connectionId, + query: sql, + ...(activeSchema ? { schema: activeSchema } : {}), + }); } onSuccess(); diff --git a/src/components/ui/NewConnectionModal.tsx b/src/components/ui/NewConnectionModal.tsx index e744393..131eee5 100644 --- a/src/components/ui/NewConnectionModal.tsx +++ b/src/components/ui/NewConnectionModal.tsx @@ -13,12 +13,12 @@ import { invoke } from "@tauri-apps/api/core"; import clsx from "clsx"; import { SshConnectionsModal } from "../modals/SshConnectionsModal"; import { SearchableSelect } from "./SearchableSelect"; +import { useDrivers } from "../../hooks/useDrivers"; +import type { PluginManifest } from "../../types/plugins"; import { loadSshConnections, type SshConnection } from "../../utils/ssh"; -type Driver = "postgres" | "mysql" | "sqlite"; - interface ConnectionParams { - driver: Driver; + driver: string; host?: string; port?: number; username?: string; @@ -98,7 +98,9 @@ export const NewConnectionModal = ({ initialConnection, }: NewConnectionModalProps) => { const { t } = useTranslation(); - const [driver, setDriver] = useState("postgres"); + const { drivers } = useDrivers(); + const [driver, setDriver] = useState("postgres"); + const activeDriver = drivers.find((d) => d.id === driver) ?? drivers[0]; const [name, setName] = useState(""); const [formData, setFormData] = useState>({ host: "localhost", @@ -134,7 +136,7 @@ export const NewConnectionModal = ({ // Load available databases for the user const loadDatabases = async () => { - if (driver === "sqlite") { + if (activeDriver?.capabilities?.file_based === true) { // SQLite doesn't support database listing return; } @@ -224,20 +226,15 @@ export const NewConnectionModal = ({ if (!isOpen) return null; - const handleDriverChange = (newDriver: Driver) => { + const handleDriverChange = (newDriver: string) => { setDriver(newDriver); // Only reset if creating new, or be careful not to wipe existing data being edited? // Let's assume switching driver resets defaults for convenience. setFormData((prev) => ({ ...prev, driver: newDriver, - port: - newDriver === "postgres" - ? 5432 - : newDriver === "mysql" - ? 3306 - : undefined, - username: newDriver === "postgres" ? "postgres" : "root", + port: drivers.find(d => d.id === newDriver)?.default_port ?? undefined, + username: newDriver === "postgres" ? "postgres" : (newDriver === "mysql" ? "root" : ""), })); setStatus("idle"); setMessage(""); @@ -411,24 +408,24 @@ export const NewConnectionModal = ({
- {(["mysql", "postgres", "sqlite"] as Driver[]).map((d) => ( + {drivers.map((d: PluginManifest) => ( ))}
- {driver !== "sqlite" && ( + {activeDriver?.capabilities?.file_based === false && (
)} - {driver !== "sqlite" && ( + {activeDriver?.capabilities?.file_based === false && (
(FALLBACK_DRIVERS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { settings } = useSettings(); + + useEffect(() => { + invoke("get_registered_drivers") + .then((result) => { + setAllDrivers(result); + setError(null); + }) + .catch((err: unknown) => { + setError(String(err)); + }) + .finally(() => setLoading(false)); + }, []); + + const builtin = ["mysql", "postgres", "sqlite"]; + const activeExt = settings.activeExternalDrivers || []; + const active = allDrivers.filter(d => builtin.includes(d.id) || activeExt.includes(d.id)); + + return { drivers: active, allDrivers, loading, error }; +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 0991bad..1e76236 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -198,7 +198,7 @@ export const Editor = () => { const [editorHeight, setEditorHeight] = useState(300); const [isResultsCollapsed, setIsResultsCollapsed] = useState(false); const isDragging = useRef(false); - const editorRef = useRef[0] | null>(null); + const editorsRef = useRef[0]>>({}); const [monacoInstance, setMonacoInstance] = useState(null); const [selectableQueries, setSelectableQueries] = useState([]); @@ -523,7 +523,7 @@ export const Editor = () => { } // Monaco Editor: handle selection and multi-query - if (!editorRef.current) { + if (!editorsRef.current[activeTab.id]) { // Fallback: use saved query when editor ref is not available (e.g. after tab restore) if (activeTab.query?.trim()) { const queries = splitQueries(activeTab.query); @@ -535,7 +535,7 @@ export const Editor = () => { } return; } - const editor = editorRef.current; + const editor = editorsRef.current[activeTab.id]; const selection = editor.getSelection(); const selectedText = selection ? editor.getModel()?.getValueInRange(selection) @@ -1265,8 +1265,8 @@ export const Editor = () => { }); }, [activeTab, updateActiveTab, applyToAll]); - const handleEditorMount: OnMount = (editor, monaco) => { - editorRef.current = editor; + const handleEditorMount = (editor: Parameters[0], monaco: Monaco, tabId: string) => { + editorsRef.current[tabId] = editor; setMonacoInstance(monaco); editor.addAction({ id: "run-selection", @@ -1471,8 +1471,8 @@ export const Editor = () => { const handleRunDropdownToggle = useCallback(() => { if (!isRunDropdownOpen) { // Monaco Editor: split queries from editor - if (activeTab?.type !== "query_builder" && editorRef.current) { - const editor = editorRef.current; + if (activeTab?.type !== "query_builder" && activeTab && editorsRef.current[activeTab.id]) { + const editor = editorsRef.current[activeTab.id]; const selection = editor.getSelection(); const selectedText = selection ? editor.getModel()?.getValueInRange(selection) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 65251e1..5a10c82 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { Heart, Info, Code2, - Settings as SettingsIcon, + Database, Settings as SettingsIcon, Languages, Sparkles, Power, @@ -37,6 +37,8 @@ import { APP_VERSION } from "../version"; import { message, ask, save } from "@tauri-apps/plugin-dialog"; import { AVAILABLE_FONTS, ROADMAP } from "../utils/settings"; import { getProviderLabel } from "../utils/settingsUI"; +import { useDrivers } from "../hooks/useDrivers"; +import type { PluginManifest } from "../types/plugins"; import { SearchableSelect } from "../components/ui/SearchableSelect"; import { useUpdate } from "../hooks/useUpdate"; @@ -55,6 +57,7 @@ interface LogEntry { const LogsTab = () => { const { t } = useTranslation(); const { settings, updateSetting } = useSettings(); + const [logs, setLogs] = useState([]); const [levelFilter, setLevelFilter] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -351,12 +354,13 @@ export const Settings = () => { const { t } = useTranslation(); const { settings, updateSetting } = useSettings(); const { checkForUpdates, isChecking, updateInfo, error: updateError, isUpToDate } = useUpdate(); - const [activeTab, setActiveTab] = useState<"general" | "appearance" | "localization" | "ai" | "updates" | "logs" | "info">( + const [activeTab, setActiveTab] = useState<"general" | "appearance" | "localization" | "ai" | "updates" | "logs" | "info" | "plugins">( "general", ); const [aiKeyStatus, setAiKeyStatus] = useState>({}); const [availableModels, setAvailableModels] = useState>({}); const [keyInput, setKeyInput] = useState(""); + const { allDrivers } = useDrivers(); const [systemPrompt, setSystemPrompt] = useState(""); const [explainPrompt, setExplainPrompt] = useState(""); @@ -590,6 +594,18 @@ export const Settings = () => { {t("settings.logs")} + +
+ ); + })} +
+
+ + + + )} + + {activeTab === "info" && (
{/* Header / Hero */}
diff --git a/src/types/plugins.ts b/src/types/plugins.ts new file mode 100644 index 0000000..893b5a1 --- /dev/null +++ b/src/types/plugins.ts @@ -0,0 +1,16 @@ +export interface DriverCapabilities { + schemas: boolean; + views: boolean; + routines: boolean; + file_based: boolean; + identifier_quote: string; +} + +export interface PluginManifest { + id: string; + name: string; + version: string; + description: string; + default_port: number | null; + capabilities: DriverCapabilities; +} diff --git a/src/utils/connections.ts b/src/utils/connections.ts index 3e21e38..c791b41 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -3,7 +3,10 @@ * Extracted from Connections.tsx for testability */ -export type DatabaseDriver = "postgres" | "mysql" | "sqlite"; +export type DatabaseDriver = string; + +export const BUILTIN_DRIVER_IDS = ["postgres", "mysql", "sqlite"] as const; +export type BuiltinDriverId = (typeof BUILTIN_DRIVER_IDS)[number]; export interface SshConnection { id: string; diff --git a/src/utils/identifiers.ts b/src/utils/identifiers.ts index 45609b1..62b7b9b 100644 --- a/src/utils/identifiers.ts +++ b/src/utils/identifiers.ts @@ -1,9 +1,18 @@ +import type { PluginManifest } from "@/types/plugins"; + /** * Returns the appropriate quote character for SQL identifiers based on the database driver. + * Accepts a driver string or a PluginManifest object. + * When a manifest is provided, the identifier_quote from capabilities is used. * MySQL/MariaDB use backticks (`), while PostgreSQL and SQLite use double quotes ("). */ -export function getQuoteChar(driver: string | null | undefined): string { - return driver === "mysql" || driver === "mariadb" ? "`" : '"'; +export function getQuoteChar(driver: string | PluginManifest | null | undefined): string { + if (typeof driver === "object" && driver?.capabilities?.identifier_quote) { + return driver.capabilities.identifier_quote; + } + // legacy fallback for string driver names + const driverStr = typeof driver === "object" ? driver?.id : driver; + return driverStr === "mysql" || driverStr === "mariadb" ? "`" : '"'; } /** diff --git a/test_sql.js b/test_sql.js new file mode 100644 index 0000000..b4d8efb --- /dev/null +++ b/test_sql.js @@ -0,0 +1,68 @@ +function splitQueries(sql) { + const queries = []; + let currentQuery = ''; + let inQuote = false; + let quoteChar = ''; + let inLineComment = false; // -- + let inBlockComment = false; // /* */ + + for (let i = 0; i < sql.length; i++) { + const char = sql[i]; + const nextChar = sql[i + 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + currentQuery += char; + } else if (inBlockComment) { + if (char === '*' && nextChar === '/') { + inBlockComment = false; + currentQuery += '*/'; + i++; + } else { + currentQuery += char; + } + } else if (inQuote) { + if (char === quoteChar) { + // Check for escape (double quote for SQL usually) - e.g. 'It''s' + if (nextChar === quoteChar) { + currentQuery += char + nextChar; + i++; + } else { + inQuote = false; + currentQuery += char; + } + } else { + currentQuery += char; + } + } else { + // Normal state + if (char === '-' && nextChar === '-') { + inLineComment = true; + currentQuery += '--'; + i++; + } else if (char === '/' && nextChar === '*') { + inBlockComment = true; + currentQuery += '/*'; + i++; + } else if (char === "'" || char === '"' || char === '`') { + inQuote = true; + quoteChar = char; + currentQuery += char; + } else if (char === ';') { + if (currentQuery.trim()) { + queries.push(currentQuery.trim()); + } + currentQuery = ''; + } else { + currentQuery += char; + } + } + } + if (currentQuery.trim()) { + queries.push(currentQuery.trim()); + } + return queries; +} + +console.log(splitQueries('SELECT * FROM users')); +console.log(splitQueries('SELECT * FROM users; SELECT 1;'));