diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 82ab98e47..7bfba1fa7 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -148,6 +148,11 @@ jobs: DISPLAY_NAME="ClickHouse Driver"; SUMMARY="ClickHouse OLAP database driver via HTTP interface" DB_TYPE_IDS='["ClickHouse"]'; ICON="chart.bar.xaxis"; BUNDLE_NAME="ClickHouseDriver" CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/clickhouse" ;; + dameng) + TARGET="DamengDriver"; BUNDLE_ID="com.TablePro.DamengDriver" + DISPLAY_NAME="Dameng Driver"; SUMMARY="Dameng DM8 database driver via a native wire client" + DB_TYPE_IDS='["Dameng"]'; ICON="cylinder"; BUNDLE_NAME="DamengDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/dameng" ;; sqlite) TARGET="SQLiteDriver"; BUNDLE_ID="com.TablePro.SQLiteDriver" DISPLAY_NAME="SQLite Driver"; SUMMARY="SQLite embedded database driver" @@ -279,6 +284,10 @@ jobs: if: ${{ contains(matrix.tag, 'plugin-cassandra-') }} run: ./scripts/build-cassandra.sh both + - name: Build Dameng native bridge + if: ${{ contains(matrix.tag, 'plugin-dameng-') }} + run: ./scripts/build-dameng.sh both + - name: Build plugin binaries env: TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index 96219f3a3..775242cae 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -100,6 +100,11 @@ jobs: GH_TOKEN: ${{ github.token }} run: scripts/download-libs.sh + # Cargo and rustup need access outside Xcode's user-script sandbox. Build the + # native bridge explicitly before compiling the driver target. + - name: Build Dameng native bridge + run: scripts/build-dameng.sh arm64 + # Secrets.xcconfig is gitignored. Tests do not need analytics keys, so an empty # value is enough for the project to resolve $(ANALYTICS_HMAC_SECRET). - name: Create Secrets.xcconfig diff --git a/.gitignore b/.gitignore index 4b091e12f..6a87fa7f2 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,10 @@ Libs/.downloaded Libs/dylibs/ Libs/ios/ +# Rust driver build outputs (keep the bridge lockfile, ignore vendored crate locks) +Native/DamengBridge/**/target/ +Native/DamengBridge/Vendor/*/Cargo.lock + # Issue analysis blueprints (local only) .analysis/ .docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac0b3477..9a0382425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Dameng DM8 connections through a downloadable native-wire plugin, with schema browsing, table editing, metadata, DDL, transactions, Unicode and binary writes, and EXPLAIN support. (#1671, #2003, #2010) + - PostgreSQL array columns of a simple type, including arrays of an enum, get a list editor in the data grid. One row per element, with reordering, add and remove, and NULL per element. An empty array and a NULL column stay separate values. Enum arrays pick from the labels the type declares. Arrays of `jsonb`, `bytea` or composite types, and multi-dimensional values, keep the plain text editor. ### Fixed diff --git a/Native/DamengBridge/Cargo.lock b/Native/DamengBridge/Cargo.lock new file mode 100644 index 000000000..29a7ad50a --- /dev/null +++ b/Native/DamengBridge/Cargo.lock @@ -0,0 +1,1041 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dameng" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "dameng-protocol", + "dameng-types", + "encoding_rs", + "native-tls", + "rust_decimal", +] + +[[package]] +name = "dameng-protocol" +version = "0.1.0" +dependencies = [ + "bytes", + "dameng-types", +] + +[[package]] +name = "dameng-types" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "encoding_rs", + "rust_decimal", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "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", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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 = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +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 = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[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.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tablepro-dameng-bridge" +version = "0.1.0" +dependencies = [ + "dameng", + "dameng-types", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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 = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[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.119", +] + +[[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.119", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Native/DamengBridge/Cargo.toml b/Native/DamengBridge/Cargo.toml new file mode 100644 index 000000000..efaba9751 --- /dev/null +++ b/Native/DamengBridge/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tablepro-dameng-bridge" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +dameng = { path = "Vendor/dameng" } +dameng-types = { path = "Vendor/dameng-types" } + +[profile.release] +codegen-units = 1 +lto = false +opt-level = "s" +panic = "unwind" +strip = "symbols" diff --git a/Native/DamengBridge/LICENSES/rust-dameng.txt b/Native/DamengBridge/LICENSES/rust-dameng.txt new file mode 100644 index 000000000..81121d744 --- /dev/null +++ b/Native/DamengBridge/LICENSES/rust-dameng.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 指令集 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Native/DamengBridge/Vendor/UPSTREAM.md b/Native/DamengBridge/Vendor/UPSTREAM.md new file mode 100644 index 000000000..9bdace4ec --- /dev/null +++ b/Native/DamengBridge/Vendor/UPSTREAM.md @@ -0,0 +1,12 @@ +# Vendored rust-dameng Snapshot + +The `dameng`, `dameng-protocol`, and `dameng-types` directories originate from +[`rarnu/rust-dameng`](https://github.com/rarnu/rust-dameng) commit +`c5120eb04abbe232ccc04e19d16093fbe6e0da0e` and are distributed under the MIT +license included in each directory. + +TablePro carries compatibility changes for DM8 multi-column responses, binary +DECIMAL values, text EXPLAIN responses, bounded frame allocation, and exact +message-boundary reads. Response bodies and LOB content are capped at 64 MiB. +Review and retest these changes against an OrbStack DM8 instance whenever the +upstream snapshot changes. diff --git a/Native/DamengBridge/Vendor/dameng-protocol/Cargo.toml b/Native/DamengBridge/Vendor/dameng-protocol/Cargo.toml new file mode 100644 index 000000000..5f1700808 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dameng-protocol" +version = "0.1.0" +edition = "2021" +description = "Dameng database wire protocol implementation" +license = "MIT" +repository = "https://github.com/rarnu/rust-dameng" +keywords = ["dameng", "database", "protocol"] +categories = ["database"] + +[dependencies] +bytes = "1" +dameng-types = { path = "../dameng-types" } + +[dev-dependencies] diff --git a/Native/DamengBridge/Vendor/dameng-protocol/LICENSE.txt b/Native/DamengBridge/Vendor/dameng-protocol/LICENSE.txt new file mode 100644 index 000000000..81121d744 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 指令集 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/error.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/error.rs new file mode 100644 index 000000000..f16660c79 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/error.rs @@ -0,0 +1,60 @@ +//! Error types for the Dameng protocol. + +use std::fmt; + +/// Errors that can occur when parsing or building Dameng protocol messages. +#[derive(Debug)] +pub enum Error { + /// Not enough bytes to parse a frame or message. + Incomplete, + /// Checksum mismatch in frame header. + ChecksumMismatch, + /// Invalid frame header (bad length, unknown version, etc.). + InvalidFrame(String), + /// Unknown message type. + UnknownMessageType(u8), + /// Failed to decode a string value. + DecodeError(String), + /// I/O error during read/write. + Io(std::io::Error), + /// Authentication failed. + AuthFailed(String), + /// Server returned an error message. + ServerError(i32, String), + /// LOB streaming operation failed (e.g. stream ended prematurely or invalid chunk). + LobStreamError(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Incomplete => write!(f, "incomplete protocol data"), + Error::ChecksumMismatch => write!(f, "checksum mismatch"), + Error::InvalidFrame(s) => write!(f, "invalid frame: {s}"), + Error::UnknownMessageType(t) => write!(f, "unknown message type: {t}"), + Error::DecodeError(s) => write!(f, "decode error: {s}"), + Error::Io(e) => write!(f, "IO error: {e}"), + Error::AuthFailed(s) => write!(f, "auth failed: {s}"), + Error::ServerError(code, msg) => write!(f, "server error {code}: {msg}"), + Error::LobStreamError(s) => write!(f, "LOB stream error: {s}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +/// Result type alias for protocol operations. +pub type Result = std::result::Result; diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/frame.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/frame.rs new file mode 100644 index 000000000..3219d119f --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/frame.rs @@ -0,0 +1,227 @@ +//! 64-byte frame header for Dameng protocol messages. +//! +//! Layout (all multi-byte values are LITTLE ENDIAN): +//! ```text +//! Offset Size Field +//! 0 4 Handle (i32 LE) +//! 4 1 MsgType (u8) +//! 5 1 Reserved (0) +//! 6 4 BodyLen (i32 LE) - length of payload after header +//! 10 4 ResponseCode (i32 LE) - filled by server +//! 14 4 AffectedRows (i32 LE) - rows affected (for DML responses) +//! 18 1 CompressFlag (u8) +//! 19 1 Checksum (u8) - XOR of bytes 0-18 +//! 20 44 Reserved (zeros) +//! 64 var Payload body +//! ``` + +use bytes::{Buf, BufMut, BytesMut}; + +use crate::error::{Error, Result}; + +/// The size of the frame header in bytes. +pub const FRAME_HEADER_SIZE: usize = 64; + +/// DM protocol frame header (64 bytes). +#[derive(Debug, Clone, PartialEq)] +pub struct Frame { + /// Statement/connection handle. + pub handle: u32, + /// Message type identifier. + pub msg_type: u8, + /// Length of the payload following this header. + pub body_len: i32, + /// Response code from server (0 for client messages). + pub response_code: i32, + /// Number of rows affected (for DML responses like INSERT/UPDATE/DELETE). + /// Set by the server in ACK/EXEC_RESPONSE frames; 0 for non-DML. + pub affected_rows: i32, + /// Compression flag (0=none, 1=snappy, 2=zlib). + pub compress_flag: u8, + /// Update count for DML operations, stored in the reserved area + /// at header offset 24 (int64 LE). Always 0 for non-DML. + pub update_count: u64, + /// Server encoding from header offset 28 (int32 LE). + /// Used in STARTUP_RESPONSE. 0=GB18030, 1=UTF-8, 2=EUC-KR. + pub server_encoding: u8, +} + +impl Frame { + /// Create a new frame header for client messages. + pub fn new(msg_type: u8, handle: u32, body_len: i32) -> Self { + Self { + handle, + msg_type, + body_len, + response_code: 0, + affected_rows: 0, + compress_flag: 0, + update_count: 0, + server_encoding: 0, + } + } + + /// Parse a frame header from a buffer. + /// + /// Returns `Err(Error::Incomplete)` if fewer than 64 bytes are available. + /// Parse a frame header from a buffer. + /// + /// Returns `Err(Error::Incomplete)` if fewer than 64 bytes are available. + pub fn parse(buf: &mut BytesMut) -> Result { + if buf.len() < FRAME_HEADER_SIZE { + return Err(Error::Incomplete); + } + + // Compute XOR checksum of bytes 0-18 BEFORE consuming + let mut calc_xor: u8 = 0; + for i in 0..19 { + calc_xor ^= buf[i]; + } + + let handle = buf.get_u32_le(); + let msg_type = buf.get_u8(); + let _reserved = buf.get_u8(); + let body_len = buf.get_i32_le(); + let response_code = buf.get_i32_le(); + let affected_rows = buf.get_i32_le(); + let compress_flag = buf.get_u8(); + let checksum = buf.get_u8(); + + if calc_xor != checksum { + return Err(Error::ChecksumMismatch); + } + + // Parse update_count from the reserved area at header offset 24 (int64 LE). + // Need to read from the raw buffer before advancing. buf currently has + // cursor at byte 20 (after consuming 20 bytes). + // Bytes [4..12] of the remaining 44-byte reserved area = absolute offset 24. + let update_count = if buf.remaining() >= 12 { + let raw = &buf.chunk()[4..12]; // offset 24-31 in absolute header + u64::from_le_bytes([ + raw[0], raw[1], raw[2], raw[3], raw[4], raw[5], raw[6], raw[7], + ]) + } else { + 0 + }; + // Server encoding at header offset 28 (int32 LE) + let server_encoding = if buf.remaining() >= 12 { + let raw = &buf.chunk()[8..12]; // offset 28-31 in absolute header + u8::from_le_bytes([raw[0]]) + } else { + 0 + }; + + // Skip remaining 44 bytes of reserved + buf.advance(44); + + Ok(Frame { + handle, + msg_type, + body_len, + response_code, + affected_rows, + compress_flag, + update_count, + server_encoding, + }) + } + + /// Encode this frame header into a `BytesMut` buffer. + pub fn encode(&self) -> BytesMut { + let mut buf = BytesMut::with_capacity(FRAME_HEADER_SIZE); + + buf.put_u32_le(self.handle); + buf.put_u8(self.msg_type); + buf.put_u8(0); // reserved + buf.put_i32_le(self.body_len); + buf.put_i32_le(self.response_code); + buf.put_i32_le(0); // reserved + buf.put_u8(self.compress_flag); + buf.put_u8(0); // checksum placeholder + + // Compute and write checksum at offset 19 + let mut cs: u8 = 0; + for i in 0..19 { + cs ^= buf[i]; + } + buf[19] = cs; + + // Fill remaining 44 bytes with zeros (20-63) + buf.put_bytes(0, 44); + + debug_assert_eq!(buf.len(), FRAME_HEADER_SIZE); + buf + } + + /// Encode frame header + payload into a single BytesMut. + pub fn encode_with_payload(&self, payload: &[u8]) -> BytesMut { + let mut buf = self.encode(); + buf.extend_from_slice(payload); + buf + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frame_new_and_fields() { + let frame = Frame::new(200, 0, 82); + assert_eq!(frame.msg_type, 200); + assert_eq!(frame.handle, 0); + assert_eq!(frame.body_len, 82); + } + + #[test] + fn test_frame_encode_size() { + let frame = Frame::new(1, 0, 59); + let encoded = frame.encode(); + assert_eq!(encoded.len(), FRAME_HEADER_SIZE); + } + + #[test] + fn test_frame_roundtrip() { + let original = Frame::new(6, 42, 128); + let mut encoded = original.encode(); + let parsed = Frame::parse(&mut encoded).unwrap(); + assert_eq!(parsed.msg_type, 6); + assert_eq!(parsed.handle, 42); + assert_eq!(parsed.body_len, 128); + } + + #[test] + fn test_frame_parse_incomplete() { + let mut buf = BytesMut::from(&[0u8; 32][..]); + let result = Frame::parse(&mut buf); + assert!(matches!(result, Err(Error::Incomplete))); + } + + #[test] + fn test_frame_encode_fields() { + let frame = Frame::new(8, 7, 255); + let encoded = frame.encode(); + let bytes = encoded.freeze(); + + assert_eq!( + i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + 7 + ); + assert_eq!(bytes[4], 8); + assert_eq!(bytes[5], 0); + assert_eq!( + i32::from_le_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]), + 255 + ); + assert_eq!(bytes[18], 0); + } + + #[test] + fn test_frame_parse_fields() { + let mut encoded = Frame::new(13, 3, 100).encode(); + let frame = Frame::parse(&mut encoded).unwrap(); + assert_eq!(frame.msg_type, 13); + assert_eq!(frame.handle, 3); + assert_eq!(frame.body_len, 100); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/lib.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/lib.rs new file mode 100644 index 000000000..f3c942a7b --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/lib.rs @@ -0,0 +1,13 @@ +//! Dameng database wire protocol implementation. +//! +//! This crate implements the binary wire protocol used to communicate +//! with Dameng database servers, based on reverse-engineered protocol captures. + +pub mod error; +pub mod frame; +pub mod message; + +pub use error::{Error, Result}; +pub use frame::Frame; +pub use message::explain::ExplainResponse; +pub use message::response::{Column, ExecResponse, Row}; diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message.rs new file mode 100644 index 000000000..dfd0f745b --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message.rs @@ -0,0 +1,178 @@ +//! Dameng protocol message types. +//! +//! This module defines all message types used in the DM wire protocol. +//! Messages are organized by their direction (client->server or server->client) +//! and their purpose in the connection lifecycle. + +pub mod bind; +pub mod close; +pub mod exec; +pub mod explain; +pub mod fetch; +pub mod isolation; +pub mod lob; +pub mod lob_bind; +pub mod login; +pub mod ready; +pub mod response; +pub mod startup; +pub mod transaction; + +pub use bind::*; +pub use close::*; +pub use exec::*; +pub use explain::*; +pub use fetch::*; +pub use isolation::*; +pub use lob::*; +pub use lob_bind::*; +pub use login::*; +pub use ready::*; +pub use response::*; +pub use startup::*; +pub use transaction::*; + +// Re-export msg_type constants at top level for convenience +pub use self::msg_type::*; + +use bytes::{BufMut, BytesMut}; + +use crate::frame::Frame; + +/// Message type constants. +pub mod msg_type { + /// STARTUP - Initial connection handshake (client->server) + pub const STARTUP: u8 = 200; + /// STARTUP_RESPONSE - Server hello (server->client) + pub const STARTUP_RESPONSE: u8 = 228; + /// LOGIN - Send credentials (client->server) + pub const LOGIN: u8 = 1; + /// LOGIN_RESPONSE - Authentication result (server->client) + pub const LOGIN_RESPONSE: u8 = 163; + /// STATEMENT_PREPARE - Allocate a statement handle (client->server). + /// Previously named READY; kept for backward compatibility. + pub const STATEMENT_PREPARE: u8 = 3; + /// READY - Alias for STATEMENT_PREPARE (type 3). Send ready/keepalive (client->server). + pub const READY: u8 = 3; + /// STATEMENT_FREE - Free a statement handle (client->server). + pub const STATEMENT_FREE: u8 = 4; + /// ACK - Success/generic response (server->client) + pub const ACK: u8 = 187; + /// PREPARE/EXEC - Prepare and execute statement (client->server) + pub const EXEC: u8 = 5; + /// EXEC_RESPONSE - Statement result (server->client) + pub const EXEC_RESPONSE: u8 = 0; + /// EXPLAIN_RESPONSE - Text query plan (server->client) + pub const EXPLAIN_RESPONSE: u8 = 149; + /// FETCH - Fetch more rows from result set (client->server) + pub const FETCH: u8 = 7; + /// COMMIT - Commit transaction (client->server) + pub const COMMIT: u8 = 8; + /// ROLLBACK - Rollback transaction (client->server) + pub const ROLLBACK: u8 = 9; + /// BIND - Legacy bind parameters and execute (client->server) + pub const BIND: u8 = 13; + /// BIND_EXEC2 - Bind parameters and execute with EXEC2 protocol (client->server) + pub const BIND_EXEC2: u8 = 90; + /// CLOSE - Close statement (client->server) + pub const CLOSE: u8 = 20; + /// STATEMENT_SET_CURSOR - Set cursor position for a statement (client->server) + pub const STATEMENT_SET_CURSOR: u8 = 27; + /// FETCH_RESULT_SET - Fetch an entire result set (client->server) + pub const FETCH_RESULT_SET: u8 = 44; + /// SET_ISOLATION - Set transaction isolation level (client->server) + pub const SET_ISOLATION: u8 = 52; + /// OPTIMIZED_PREPARE_EXEC - Optimized prepare-and-execute path (client->server) + pub const OPTIMIZED_PREPARE_EXEC: u8 = 91; + /// LOB_FREE - Free a LOB locator (client->server) + pub const LOB_FREE: u8 = 29; + /// LOB_GETLEN - Get LOB length (client->server) + pub const LOB_GETLEN: u8 = 31; + /// LOB_READ - Read LOB data chunk (client->server) + pub const LOB_READ: u8 = 32; +} + +/// DM data type codes. +pub mod dm_type { + pub const BIT: i32 = 1; + pub const TINYINT: i32 = 2; + pub const VARCHAR: i32 = 3; + pub const INT: i32 = 4; + pub const BIGINT: i32 = 5; + pub const SMALLINT: i32 = 6; + pub const FLOAT: i32 = 7; + pub const DOUBLE: i32 = 8; + pub const DECIMAL: i32 = 9; + pub const DATE: i32 = 10; + pub const TIME: i32 = 11; + pub const TIMESTAMP: i32 = 12; + pub const BLOB: i32 = 13; + pub const CLOB: i32 = 14; + pub const INTERVAL: i32 = 15; + pub const CHAR: i32 = 16; + pub const BINARY: i32 = 17; + pub const VARBINARY: i32 = 18; + pub const NUMERIC: i32 = 20; + pub const BOOLEAN: i32 = 21; + pub const DATETIME: i32 = 22; + pub const VARCHAR2: i32 = 23; + pub const DATETIME2: i32 = 24; + pub const TIME_TZ: i32 = 25; + pub const DATETIME_TZ: i32 = 26; + pub const INTERVAL_YM: i32 = 27; + pub const INTERVAL_DT: i32 = 28; + pub const RAW: i32 = 29; + pub const DATETIME2_TZ: i32 = 30; + pub const REAL: i32 = 31; +} + +/// DM encoding values. +pub mod encoding { + pub const UTF8: u8 = 1; + pub const GB18030: u8 = 2; +} + +/// Language ID values. +pub mod language { + pub const EN: u16 = 1; + pub const CN: u16 = 2; +} + +/// Encryption utility functions. +/// DM uses a simple XOR encryption for credentials. +pub mod crypto { + + /// Generate encrypted credentials using the server's challenge. + /// The algorithm XORs the plaintext with the challenge bytes, cycling through. + pub fn encrypt_with_challenge(plaintext: &[u8], challenge: &[u8], output: &mut [u8]) { + let challenge_len = challenge.len(); + if challenge_len == 0 { + output.copy_from_slice(plaintext); + return; + } + for (i, (out, &plain)) in output.iter_mut().zip(plaintext.iter()).enumerate() { + *out = plain ^ challenge[i % challenge_len]; + } + } + + /// Build the startup message encrypted random key from server challenge. + pub fn build_startup_key(challenge: &[u8]) -> [u8; 64] { + let mut key = [0u8; 64]; + // Generate a simple key pattern based on challenge + let challenge_len = challenge.len(); + if challenge_len > 0 { + for i in 0..64 { + key[i] = challenge[i % challenge_len] ^ (i as u8); + } + } + key + } +} + +/// Build a complete message (frame + payload) and return it as BytesMut. +pub fn build_message(msg_type: u8, handle: u32, payload: &[u8]) -> BytesMut { + let frame = Frame::new(msg_type, handle, payload.len() as i32); + let mut result = frame.encode(); + result.put_slice(payload); + result +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/bind.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/bind.rs new file mode 100644 index 000000000..3b25dec73 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/bind.rs @@ -0,0 +1,579 @@ +//! BIND message (type 13) for binding parameters to prepared statements. +//! +//! Also includes statement handle management messages: +//! - StatementAllocate (type 3): allocate a new statement handle +//! - StatementFree (type 4): free a statement handle + +use bytes::{BufMut, BytesMut}; + +use crate::error::Result; + +/// Parameter direction for binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParameterDirection { + /// Input parameter (default). + Input = 1, + /// Output parameter. + Output = 2, + /// Input/Output parameter. + InputOutput = 3, +} + +/// A single parameter to bind. +#[derive(Debug, Clone)] +pub struct BindParam { + /// SQL type name (e.g., "INT", "VARCHAR"). + pub type_name: String, + /// DM type code. + pub type_code: i32, + /// Precision for numeric types. + pub precision: i32, + /// Scale for numeric types. + pub scale: i32, + /// Parameter direction. + pub direction: ParameterDirection, + /// The parameter value as bytes (None = NULL). + pub value: Option>, +} + +/// Client->Server BIND message (type 13). +/// +/// Legacy format — kept for backward compatibility. +#[derive(Debug, Clone)] +pub struct BindMessage { + /// Whether to fetch results after binding. + pub fetch_flag: u8, + /// Parameters to bind. + pub params: Vec, +} + +impl BindMessage { + /// Create a new bind message. + pub fn new(fetch: bool, params: Vec) -> Self { + Self { + fetch_flag: if fetch { 1 } else { 0 }, + params, + } + } + + /// Encode to payload bytes (legacy format). + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + + buf.put_u8(self.fetch_flag); + buf.put_u8(0); // reserved + buf.put_u16_le(0); // reserved + buf.put_u16_le(self.params.len() as u16); + buf.put_u16_le(0); // reserved + buf.put_u32_le(0); // reserved + buf.put_u32_le(0); // reserved + buf.put_u32_le(0); // reserved + buf.put_u32_le(0); // reserved + buf.put_u32_le(0); // reserved + + for param in &self.params { + // Type name + let tn = param.type_name.as_bytes(); + buf.put_u16_le(tn.len() as u16); + buf.put_slice(tn); + + // Type code, precision, scale + buf.put_u32_le(param.type_code as u32); + buf.put_u32_le(param.precision as u32); + buf.put_u16_le(param.scale as u16); + + // Value + if let Some(ref val) = param.value { + buf.put_u16_le(val.len() as u16); + buf.put_u16_le(0); // reserved + buf.put_slice(val); + } else { + buf.put_u16_le(0xFFFF); // NULL marker + } + } + + buf + } +} + +/// Client->Server BIND_EXEC2 message (type 13). +/// +/// Matches the Go driver's BIND_EXEC2 format exactly. +/// Used to execute a prepared statement with bound parameters. +/// +/// Wire format: +/// ```text +/// Offset Size Field +/// 0 1 auto_commit (0 or 1) +/// 1 2 param_count (u16 LE) +/// 3 1 has_result_set (1=SELECT, 0=DML) +/// 4 8 offset (i64 LE) - pagination start +/// 12 8 cursor_update_row (i64 LE) +/// 20 8 max_rows (i64 LE) - 0=unlimited +/// 28 1 flags +/// 29 3 reserved +/// 32 4 query_timeout (i32 LE) - 0=default +/// 36 4 batch_allow_max_errors (i32 LE) +/// 40 1 innerExec +/// 41 1 bind_options (MsgVersion >= 8) +/// 42 N Parameter descriptors + values +/// ``` +#[derive(Debug, Clone)] +pub struct BindExec2Message { + /// Auto-commit mode (true = commit after execution). + pub auto_commit: bool, + /// Whether this query returns a result set (SELECT vs DML). + pub has_result_set: bool, + /// Pagination offset (0 = start from beginning). + pub offset: i64, + /// Maximum rows to return (0 = unlimited). + pub max_rows: i64, + /// Query timeout in seconds (0 = default). + pub query_timeout: i32, + /// Parameters to bind. + pub params: Vec, +} + +impl BindExec2Message { + /// Create a new BIND_EXEC2 message. + pub fn new(auto_commit: bool, has_result_set: bool, params: Vec) -> Self { + Self { + auto_commit, + has_result_set, + offset: 0, + max_rows: 0, + query_timeout: 0, + params, + } + } + + /// Create with pagination. + pub fn with_pagination( + auto_commit: bool, + has_result_set: bool, + offset: i64, + max_rows: i64, + params: Vec, + ) -> Self { + Self { + auto_commit, + has_result_set, + offset, + max_rows, + query_timeout: 0, + params, + } + } + + /// Encode to payload bytes. + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + + // Header + buf.put_u8(if self.auto_commit { 1 } else { 0 }); + buf.put_u16_le(self.params.len() as u16); + buf.put_u8(if self.has_result_set { 1 } else { 0 }); + + // Pagination and execution options + buf.put_i64_le(self.offset); // offset + buf.put_i64_le(0); // cursor_update_row + buf.put_i64_le(self.max_rows); // max_rows + buf.put_u8(0); // flags + buf.put_u8(0); // reserved + buf.put_u8(0); // reserved + buf.put_u8(0); // reserved + buf.put_i32_le(self.query_timeout); // query_timeout + buf.put_i32_le(0); // batch_allow_max_errors + buf.put_u8(0); // innerExec + buf.put_u8(0); // bind_options + + // Parameter descriptors + for param in &self.params { + buf.put_u8(param.direction as u8); // ioType + buf.put_i32_le(param.type_code); // colType + buf.put_i32_le(param.precision); // prec + buf.put_i32_le(param.scale); // scale + } + + // Parameter values + for param in &self.params { + match ¶m.value { + None => { + // NULL marker: -1 as u16 + buf.put_u16_le(0xFFFF); + } + Some(val) if val.len() > 0xFFFF => { + // Large data marker: -2 (0xFFFE) + 4-byte length + data + buf.put_u16_le(0xFFFE); + buf.put_u32_le(val.len() as u32); + buf.put_slice(val); + } + Some(val) => { + // Regular value: length + data + buf.put_u16_le(val.len() as u16); + buf.put_slice(val); + } + } + } + + buf + } +} + +/// Client->Server STATEMENT_PREPARE message (type 3). +/// +/// Allocates a new statement handle from the server. +/// Payload: 1 byte (readBaseColName flag, 1 = include base column names). +/// +/// Response: statement ID at offset 0 of the response payload (u32 LE). +#[derive(Debug, Clone)] +pub struct StatementAllocateMessage { + /// Whether to include base column names in responses. + pub read_base_col_name: bool, +} + +impl StatementAllocateMessage { + /// Create with default settings. + pub fn new() -> Self { + Self { + read_base_col_name: true, + } + } + + /// Encode to payload bytes. + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + buf.put_u8(if self.read_base_col_name { 1 } else { 0 }); + buf + } + + /// Parse the statement ID from the response payload. + pub fn parse_response(payload: &[u8]) -> Result { + if payload.len() < 4 { + return Err(crate::error::Error::Incomplete); + } + let stmt_id = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); + Ok(stmt_id) + } +} + +/// Client->Server STATEMENT_FREE message (type 4). +/// +/// Frees a previously allocated statement handle. +/// Payload: statement ID as u32 LE. +#[derive(Debug, Clone)] +pub struct StatementFreeMessage { + /// Statement handle to free. + pub stmt_id: u32, +} + +impl StatementFreeMessage { + /// Create to free the given statement. + pub fn new(stmt_id: u32) -> Self { + Self { stmt_id } + } + + /// Encode to payload bytes. + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + buf.put_u32_le(self.stmt_id); + buf + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- BindMessage tests --- + + #[test] + fn test_bind_new() { + let bind = BindMessage::new(true, vec![]); + assert_eq!(bind.fetch_flag, 1); + assert!(bind.params.is_empty()); + } + + #[test] + fn test_bind_encode_with_param() { + let params = vec![BindParam { + type_name: "INT".to_string(), + type_code: 4, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(vec![0xC8, 0x03, 0x00, 0x00]), + }]; + let bind = BindMessage::new(true, params); + let payload = bind.encode_payload(); + assert!(payload.len() > 30); + let param_count = u16::from_le_bytes([payload[4], payload[5]]); + assert_eq!(param_count, 1); + } + + #[test] + fn test_bind_encode_no_fetch() { + let bind = BindMessage::new(false, vec![]); + assert_eq!(bind.fetch_flag, 0); + let payload = bind.encode_payload(); + assert_eq!(payload[0], 0); + } + + #[test] + fn test_bind_multiple_params() { + let params = vec![ + BindParam { + type_name: "INT".to_string(), + type_code: 4, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(vec![1, 0, 0, 0]), + }, + BindParam { + type_name: "VARCHAR".to_string(), + type_code: 3, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(b"test".to_vec()), + }, + ]; + let bind = BindMessage::new(true, params); + let payload = bind.encode_payload(); + assert_eq!(u16::from_le_bytes([payload[4], payload[5]]), 2); + } + + #[test] + fn test_bind_param_fields() { + let param = BindParam { + type_name: "BIGINT".to_string(), + type_code: 5, + precision: 19, + scale: 0, + direction: ParameterDirection::Input, + value: Some(vec![0; 8]), + }; + assert_eq!(param.type_name, "BIGINT"); + assert_eq!(param.type_code, 5); + } + + #[test] + fn test_bind_encode_vchar_param() { + let params = vec![BindParam { + type_name: "VARCHAR".to_string(), + type_code: 3, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(b"BindTest".to_vec()), + }]; + let bind = BindMessage::new(true, params); + let payload = bind.encode_payload(); + assert!(payload.windows(8).any(|w| w == b"BindTest")); + } + + // --- BindExec2Message tests --- + + #[test] + fn test_bind_exec2_new() { + let msg = BindExec2Message::new(true, true, vec![]); + assert!(msg.auto_commit); + assert!(msg.has_result_set); + assert!(msg.params.is_empty()); + } + + #[test] + fn test_bind_exec2_encode_header() { + let msg = BindExec2Message::new(false, true, vec![]); + let payload = msg.encode_payload(); + // Minimum header is 42 bytes + assert!(payload.len() >= 42); + assert_eq!(payload[0], 0); // auto_commit = false + assert_eq!(payload[1], 0); // param_count low + assert_eq!(payload[2], 0); // param_count high + assert_eq!(payload[3], 1); // has_result_set = true + } + + #[test] + fn test_bind_exec2_encode_with_params() { + let params = vec![ + BindParam { + type_name: "INT".to_string(), + type_code: 4, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(42i32.to_le_bytes().to_vec()), + }, + BindParam { + type_name: "VARCHAR".to_string(), + type_code: 3, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(b"hello".to_vec()), + }, + ]; + let msg = BindExec2Message::new(true, true, params); + let payload = msg.encode_payload(); + // Header(42) + 2 descriptors(16 each) + values + assert!(payload.len() > 42); + // param_count at offset 1-2 + assert_eq!(u16::from_le_bytes([payload[1], payload[2]]), 2); + } + + #[test] + fn test_bind_exec2_null_param() { + let params = vec![BindParam { + type_name: "INT".to_string(), + type_code: 4, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: None, // NULL + }]; + let msg = BindExec2Message::new(true, false, params); + let payload = msg.encode_payload(); + // After header(42) + descriptor(13) = 55, null marker should be 0xFFFF + let null_marker_offset = 42 + 13; // descriptor = 1+4+4+4 = 13 + assert_eq!( + u16::from_le_bytes([payload[null_marker_offset], payload[null_marker_offset + 1]]), + 0xFFFF + ); + } + + #[test] + fn test_bind_exec2_pagination() { + let msg = BindExec2Message::with_pagination(true, true, 100, 50, vec![]); + let payload = msg.encode_payload(); + // offset at bytes 4-11 + assert_eq!( + i64::from_le_bytes([ + payload[4], + payload[5], + payload[6], + payload[7], + payload[8], + payload[9], + payload[10], + payload[11] + ]), + 100 + ); + // max_rows at bytes 20-27 + assert_eq!( + i64::from_le_bytes([ + payload[20], + payload[21], + payload[22], + payload[23], + payload[24], + payload[25], + payload[26], + payload[27] + ]), + 50 + ); + } + + #[test] + fn test_bind_exec2_output_param() { + let params = vec![BindParam { + type_name: "INT".to_string(), + type_code: 4, + precision: 0, + scale: 0, + direction: ParameterDirection::Output, + value: None, + }]; + let msg = BindExec2Message::new(true, false, params); + let payload = msg.encode_payload(); + // ioType at offset 42 (after 42-byte header) + assert_eq!(payload[42], 2); // Output = 2 + } + + // --- StatementAllocateMessage tests --- + + #[test] + fn test_statement_allocate_new() { + let msg = StatementAllocateMessage::new(); + assert!(msg.read_base_col_name); + } + + #[test] + fn test_statement_allocate_encode() { + let msg = StatementAllocateMessage::new(); + let payload = msg.encode_payload(); + assert_eq!(payload.len(), 1); + assert_eq!(payload[0], 1); + } + + #[test] + fn test_statement_allocate_encode_no_base_col() { + let msg = StatementAllocateMessage { + read_base_col_name: false, + }; + let payload = msg.encode_payload(); + assert_eq!(payload[0], 0); + } + + #[test] + fn test_statement_allocate_parse_response() { + let mut data = vec![0u8; 4]; + let stmt_id: u32 = 0x12345678; + data.copy_from_slice(&stmt_id.to_le_bytes()); + assert_eq!( + StatementAllocateMessage::parse_response(&data).unwrap(), + stmt_id + ); + } + + #[test] + fn test_statement_allocate_parse_response_incomplete() { + let data = vec![0u8; 3]; + let result = StatementAllocateMessage::parse_response(&data); + assert!(matches!(result, Err(crate::error::Error::Incomplete))); + } + + #[test] + fn test_statement_allocate_parse_zero_id() { + let data = vec![0u8; 32]; + assert_eq!(StatementAllocateMessage::parse_response(&data).unwrap(), 0); + } + + // --- StatementFreeMessage tests --- + + #[test] + fn test_statement_free_new() { + let msg = StatementFreeMessage::new(42); + assert_eq!(msg.stmt_id, 42); + } + + #[test] + fn test_statement_free_encode() { + let msg = StatementFreeMessage::new(0xDEADBEEF); + let payload = msg.encode_payload(); + assert_eq!(payload.len(), 4); + assert_eq!( + u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]), + 0xDEADBEEF + ); + } + + #[test] + fn test_statement_free_zero_id() { + let msg = StatementFreeMessage::new(0); + let payload = msg.encode_payload(); + assert_eq!(payload, vec![0u8; 4]); + } + + // --- ParameterDirection tests --- + + #[test] + fn test_parameter_direction_values() { + assert_eq!(ParameterDirection::Input as u8, 1); + assert_eq!(ParameterDirection::Output as u8, 2); + assert_eq!(ParameterDirection::InputOutput as u8, 3); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/close.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/close.rs new file mode 100644 index 000000000..7cf786568 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/close.rs @@ -0,0 +1,41 @@ +//! CLOSE message (type 20) for closing a statement handle. + +use bytes::BytesMut; + +/// Client->Server CLOSE message (type 20). +/// +/// Closes a previously prepared statement, freeing server resources. +#[derive(Debug, Clone)] +pub struct CloseMessage; + +impl CloseMessage { + /// Encode to payload bytes (empty payload). + pub fn encode_payload(&self) -> BytesMut { + BytesMut::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_close_encode_empty() { + let close = CloseMessage; + let payload = close.encode_payload(); + assert!(payload.is_empty()); + } + + #[test] + fn test_close_debug() { + let close = CloseMessage; + let debug_str = format!("{:?}", close); + assert!(debug_str.contains("CloseMessage")); + } + + #[test] + fn test_close_clone() { + let close = CloseMessage; + let _cloned = close.clone(); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/exec.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/exec.rs new file mode 100644 index 000000000..d382c06e0 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/exec.rs @@ -0,0 +1,437 @@ +//! EXEC message (type 5) for preparing and executing SQL statements. +//! +//! Two variants are provided: +//! - `ExecMessage`: simple format (SQL + null terminator) for direct execution. +//! - `ExecMessageV2`: full Go driver-compatible format with auto_commit, +//! has_result_set, exec_type, max_rows, timeout, and UTF-16 SQL encoding. + +use bytes::{BufMut, BytesMut}; + +/// Client->Server EXEC message (type 5). +/// +/// Simple format: SQL string followed by a null terminator. +/// Used for direct execution without parameters. +#[derive(Debug, Clone)] +pub struct ExecMessage { + /// Whether this is a prepared statement (1) or direct execution (0). + pub is_prepared: u8, + /// Number of parameters in the SQL. + pub param_count: u16, + /// The SQL string. + pub sql: String, +} + +impl ExecMessage { + /// Create a new direct execution message. + pub fn new(sql: &str, param_count: u16) -> Self { + Self { + is_prepared: if param_count > 0 { 1 } else { 0 }, + param_count, + sql: sql.to_string(), + } + } + + /// Create a new prepared statement message. + pub fn prepare(sql: &str, param_count: u16) -> Self { + Self { + is_prepared: 1, + param_count, + sql: sql.to_string(), + } + } + + /// Encode to payload bytes (simple format: SQL + null terminator). + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + buf.put_slice(self.sql.as_bytes()); + buf.put_u8(0); // null terminator + buf + } +} + +/// Client->Server EXEC message for PREPARE (type 5). +/// +/// This matches the Go driver's EXEC(5) format used for preparing +/// statements with parameters. The server parses this to extract +/// parameter metadata before the BIND_EXEC2 step. +/// +/// Wire format: +/// ```text +/// Offset Size Field +/// 0 1 auto_commit (0/1) +/// 1 1 is_prepare (0=prepare, 1=exec) +/// 2 1 reserved (0) +/// 3 1 exec_flag (1) +/// 4 1 reserved (0) +/// 5 2 exec_type (i16 LE) = 0 +/// 7 8 max_rows (i64 LE) = INT64_MAX +/// 15 1 bdta_flag (0/2) +/// 16 2 reserved (0) +/// 18 1 bind_options (0 for MsgVersion < 8) +/// 19 1 reserved (0) +/// 20 1 reserved (0) +/// 21 4 query_timeout (i32 LE) = 0 +/// 25 1 inner_exec (0/1) +/// 26 N SQL text (raw bytes + null terminator) +/// ``` +/// +/// For MsgVersion >= 3 (default for DM 8.x), an extra byte follows the 26-byte +/// header: the result-set-encoding byte. 0 = default row format. +#[derive(Debug, Clone)] +pub struct PrepareMessage { + /// The SQL string to prepare. + pub sql: String, + /// Whether this returns a result set (SELECT). + pub has_result_set: bool, + /// Auto-commit mode. + pub auto_commit: bool, + /// DM protocol message version (defaults to 8 as of DM 8.x). + /// Controls whether the extra result-set-encoding byte is emitted. + pub msg_version: i32, +} + +impl PrepareMessage { + /// Create a new PREPARE message. + pub fn new(sql: &str, has_result_set: bool) -> Self { + Self { + sql: sql.to_string(), + has_result_set, + auto_commit: true, + msg_version: 8, + } + } + + /// Encode to payload bytes matching Go driver's EXEC(5) format. + /// + /// Layout is the 27-byte exec params header (26 bytes + optional byte for + /// MsgVersion >= 3) + raw SQL bytes + null terminator. + /// Matches Go dm_build_784.dm_build_421() implementation. + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + + // 0: auto_commit + buf.put_u8(if self.auto_commit { 1 } else { 0 }); + // 1: has_result_set (matches Go byte 1 — dm_build_785) + buf.put_u8(if self.has_result_set { 1 } else { 0 }); + // 2: reserved + buf.put_u8(0); + // 3: exec_flag = 1 + buf.put_u8(1); + // 4: reserved + buf.put_u8(0); + // 5-6: exec_type (i16 LE) = 0 + buf.put_i16_le(0); + // 7-14: max_rows (i64 LE) = INT64_MAX + buf.put_i64_le(i64::MAX); + // 15: bdta_flag = 0 + buf.put_u8(0); + // 16-17: reserved (i16 LE) = 0 + buf.put_i16_le(0); + // 18: bind_options = 1 (Go driver writes 1 here — critical for BIND_EXEC2) + buf.put_u8(1); + // 19: reserved + buf.put_u8(0); + // 20: reserved + buf.put_u8(0); + // 21-24: query_timeout (i32 LE) = 0 + buf.put_i32_le(0); + // 25: inner_exec = 0 + buf.put_u8(0); + + // Extra byte for MsgVersion >= 3 (Go driver Dm_build_783 / offset 38). + // Result-set encoding byte: 0 = default row format. + if self.msg_version >= 3 { + let rs_encoding: u8 = 0; // Dm_build_537 = 0x00 + buf.put_u8(rs_encoding); + } + + // SQL text as raw bytes + null terminator (no length prefix) + buf.put_slice(self.sql.as_bytes()); + buf.put_u8(0); // null terminator + + buf + } +} + +/// Client->Server EXEC message v2 (type 5). +/// +/// Full Go driver-compatible format. Supports: +/// - auto_commit control +/// - has_result_set flag (SELECT vs DML) +/// - max_rows for pagination +/// - query timeout +/// - UTF-16 LE encoded SQL string +/// +/// Wire format: +/// ```text +/// Offset Size Field +/// 0 1 auto_commit (0 or 1) +/// 1 1 has_result_set (1=SELECT, 0=DML) +/// 2 4 reserved (zeros) +/// 6 2 exec_type (u16 LE) +/// 8 8 max_rows (i64 LE) - 0=unlimited +/// 16 1 bdta flag +/// 17 4 timeout (i32 LE) - 0=default +/// 21 2 bind_options (u16 LE) +/// 23 2 sql_length (u16 LE) - number of UTF-16 chars +/// 25 N SQL text (UTF-16 LE, 2 bytes per char) +/// ``` +#[derive(Debug, Clone)] +pub struct ExecMessageV2 { + /// Auto-commit mode (true = commit after execution). + pub auto_commit: bool, + /// Whether this query returns a result set. + pub has_result_set: bool, + /// Maximum rows to return (0 = unlimited). + pub max_rows: i64, + /// Query timeout in seconds (0 = default). + pub timeout: i32, + /// The SQL string. + pub sql: String, +} + +impl ExecMessageV2 { + /// Create a new v2 exec message with default settings. + pub fn new(sql: &str) -> Self { + Self { + auto_commit: true, + has_result_set: sql.trim_start().to_uppercase().starts_with("SELECT"), + max_rows: 0, + timeout: 0, + sql: sql.to_string(), + } + } + + /// Create for DML statements (INSERT/UPDATE/DELETE). + pub fn dml(sql: &str) -> Self { + Self { + auto_commit: true, + has_result_set: false, + max_rows: 0, + timeout: 0, + sql: sql.to_string(), + } + } + + /// Create for SELECT statements. + pub fn select(sql: &str) -> Self { + Self { + auto_commit: true, + has_result_set: true, + max_rows: 0, + timeout: 0, + sql: sql.to_string(), + } + } + + /// Encode to payload bytes (v2 format with UTF-16 SQL). + /// + /// NOTE: For EXEC(5) with stmt_id (PREPARE), the server expects UTF-8 + /// encoded SQL, NOT UTF-16. Use `encode_payload_utf8()` for that case. + /// This UTF-16 format is used for OPTIMIZED_PREPARE_EXEC(91) only. + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + + // Header + buf.put_u8(if self.auto_commit { 1 } else { 0 }); + buf.put_u8(if self.has_result_set { 1 } else { 0 }); + buf.put_u32_le(0); // reserved + buf.put_u16_le(0); // exec_type + + // Execution options + buf.put_i64_le(self.max_rows); // max_rows + buf.put_u8(0); // bdta flag + buf.put_i32_le(self.timeout); // timeout + buf.put_u16_le(0); // bind_options + + // SQL as UTF-16 LE + let sql_utf16: Vec = self.sql.encode_utf16().collect(); + buf.put_u16_le(sql_utf16.len() as u16); // sql_length (char count) + for &ch in &sql_utf16 { + buf.put_u16_le(ch); + } + + buf + } + + /// Encode SQL as UTF-8 (for EXEC(5) PREPARE step matching Go driver). + pub fn encode_payload_utf8(&self) -> BytesMut { + let mut buf = BytesMut::new(); + + // Header + buf.put_u8(if self.auto_commit { 1 } else { 0 }); + buf.put_u8(if self.has_result_set { 1 } else { 0 }); + buf.put_u32_le(0); // reserved + buf.put_u16_le(0); // exec_type + + // Execution options + buf.put_i64_le(self.max_rows); // max_rows + buf.put_u8(0); // bdta flag + buf.put_i32_le(self.timeout); // timeout + buf.put_u16_le(0); // bind_options + + // SQL as UTF-8 with length prefix (4-byte LE) + null terminator + let sql_bytes = self.sql.as_bytes(); + buf.put_u32_le(sql_bytes.len() as u32); // sql_length (byte count) + buf.put_slice(sql_bytes); + buf.put_u8(0); // null terminator + + buf + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- ExecMessage (simple) tests --- + + #[test] + fn test_exec_new_direct() { + let exec = ExecMessage::new("SELECT * FROM SAMPLE", 0); + assert_eq!(exec.is_prepared, 0); + assert_eq!(exec.param_count, 0); + } + + #[test] + fn test_exec_new_prepared() { + let exec = ExecMessage::new("SELECT * FROM SAMPLE WHERE ID = ?", 1); + assert_eq!(exec.is_prepared, 1); + assert_eq!(exec.param_count, 1); + } + + #[test] + fn test_exec_encode_contains_sql() { + let sql = "DELETE FROM SAMPLE WHERE ID = 998"; + let exec = ExecMessage::new(sql, 0); + let payload = exec.encode_payload(); + let sql_in_payload = &payload[payload.len() - sql.len() - 1..payload.len() - 1]; + assert_eq!(sql_in_payload, sql.as_bytes()); + } + + #[test] + fn test_exec_encode_null_terminated() { + let exec = ExecMessage::new("COMMIT", 0); + let payload = exec.encode_payload(); + assert_eq!(payload[payload.len() - 1], 0); + } + + #[test] + fn test_exec_prepare() { + let exec = ExecMessage::prepare("INSERT INTO SAMPLE VALUES (?, ?)", 2); + assert_eq!(exec.is_prepared, 1); + assert_eq!(exec.param_count, 2); + assert_eq!(exec.sql, "INSERT INTO SAMPLE VALUES (?, ?)"); + } + + #[test] + fn test_exec_payload_size() { + let exec = ExecMessage::new("SELECT 1", 0); + let payload = exec.encode_payload(); + assert_eq!(payload.len(), "SELECT 1".len() + 1); + } + + // --- ExecMessageV2 tests --- + + #[test] + fn test_exec_v2_new_select() { + let exec = ExecMessageV2::new("SELECT * FROM SAMPLE"); + assert!(exec.auto_commit); + assert!(exec.has_result_set); + assert_eq!(exec.max_rows, 0); + } + + #[test] + fn test_exec_v2_dml() { + let exec = ExecMessageV2::dml("DELETE FROM SAMPLE WHERE ID = 1"); + assert!(exec.auto_commit); + assert!(!exec.has_result_set); + } + + #[test] + fn test_exec_v2_select() { + let exec = ExecMessageV2::select("SELECT ID FROM SAMPLE"); + assert!(exec.has_result_set); + } + + #[test] + fn test_exec_v2_encode_minimal() { + let exec = ExecMessageV2::new("SELECT 1"); + let payload = exec.encode_payload(); + // Header: 25 bytes + UTF-16 SQL (8 chars * 2 = 16 bytes for "SELECT 1") + assert_eq!(payload.len(), 41); + assert_eq!(payload[0], 1); // auto_commit + assert_eq!(payload[1], 1); // has_result_set (SELECT inferred) + } + + #[test] + fn test_exec_v2_encode_sql_utf16() { + let exec = ExecMessageV2::new("AB"); + let payload = exec.encode_payload(); + // After 25-byte header: sql_len(u16) + 'A'(u16 LE) + 'B'(u16 LE) + assert_eq!(payload[23], 2); // sql_length low byte + assert_eq!(payload[24], 0); // sql_length high byte + assert_eq!(payload[25], 0x41); + assert_eq!(payload[26], 0); // 'A' + assert_eq!(payload[27], 0x42); + assert_eq!(payload[28], 0); // 'B' + } + + #[test] + fn test_exec_v2_encode_cjk_utf16() { + let exec = ExecMessageV2::new("测"); + let payload = exec.encode_payload(); + // '测' = U+6D4B -> LE bytes: 0x4B 0x6D + assert_eq!(payload[25], 0x4B); + assert_eq!(payload[26], 0x6D); + } + + #[test] + fn test_exec_v2_no_auto_commit() { + let mut exec = ExecMessageV2::select("SELECT 1"); + exec.auto_commit = false; + let payload = exec.encode_payload(); + assert_eq!(payload[0], 0); + } + + #[test] + fn test_exec_v2_max_rows() { + let mut exec = ExecMessageV2::select("SELECT 1"); + exec.max_rows = 100; + let payload = exec.encode_payload(); + // max_rows at offset 8-15 + assert_eq!( + i64::from_le_bytes([ + payload[8], + payload[9], + payload[10], + payload[11], + payload[12], + payload[13], + payload[14], + payload[15] + ]), + 100 + ); + } + + #[test] + fn test_exec_v2_timeout() { + let mut exec = ExecMessageV2::select("SELECT 1"); + exec.timeout = 30; + let payload = exec.encode_payload(); + // timeout at offset 17-20 + assert_eq!( + i32::from_le_bytes([payload[17], payload[18], payload[19], payload[20]]), + 30 + ); + } + + #[test] + fn test_exec_v2_empty_sql() { + let exec = ExecMessageV2::new(""); + let payload = exec.encode_payload(); + assert_eq!(payload.len(), 25); // header only + assert_eq!(u16::from_le_bytes([payload[23], payload[24]]), 0); // sql_length = 0 + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/explain.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/explain.rs new file mode 100644 index 000000000..7b670418a --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/explain.rs @@ -0,0 +1,76 @@ +//! EXPLAIN response (type 149). +//! +//! DM8 returns a four-byte little-endian text length followed by a textual +//! execution plan encoded with the server connection encoding. + +use dameng_types::encoding::{decode_from_server, ServerEncoding}; + +use crate::error::{Error, Result}; + +/// Upper bound for a textual query plan accepted from the server. +pub const MAX_EXPLAIN_TEXT_BYTES: usize = 16 * 1024 * 1024; + +/// Parsed textual response for `EXPLAIN `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExplainResponse { + /// UTF-8 query plan text. + pub plan: String, +} + +impl ExplainResponse { + /// Parse a length-prefixed plan from a DM8 EXPLAIN response. + pub fn from_bytes(data: &[u8], server_encoding: ServerEncoding) -> Result { + let length_bytes: [u8; 4] = data + .get(..4) + .ok_or(Error::Incomplete)? + .try_into() + .map_err(|_| Error::Incomplete)?; + let text_length = u32::from_le_bytes(length_bytes) as usize; + if text_length > MAX_EXPLAIN_TEXT_BYTES { + return Err(Error::InvalidFrame(format!( + "EXPLAIN text length {text_length} exceeds {MAX_EXPLAIN_TEXT_BYTES} bytes" + ))); + } + let text = data.get(4..4 + text_length).ok_or(Error::Incomplete)?; + + Ok(Self { + plan: decode_from_server(server_encoding, text), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_utf8_plan() { + let text = "1 #NSET2\n2 #CSCN2"; + let mut payload = (text.len() as u32).to_le_bytes().to_vec(); + payload.extend_from_slice(text.as_bytes()); + + let response = ExplainResponse::from_bytes(&payload, ServerEncoding::Utf8).unwrap(); + + assert_eq!(response.plan, text); + } + + #[test] + fn rejects_truncated_plan() { + let payload = [8, 0, 0, 0, b'p', b'l', b'a', b'n']; + + assert!(matches!( + ExplainResponse::from_bytes(&payload, ServerEncoding::Utf8), + Err(Error::Incomplete) + )); + } + + #[test] + fn rejects_oversized_plan() { + let payload = ((MAX_EXPLAIN_TEXT_BYTES + 1) as u32).to_le_bytes().to_vec(); + + assert!(matches!( + ExplainResponse::from_bytes(&payload, ServerEncoding::Utf8), + Err(Error::InvalidFrame(_)) + )); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/fetch.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/fetch.rs new file mode 100644 index 000000000..40f6cd734 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/fetch.rs @@ -0,0 +1,323 @@ +//! FETCH message (type 7) for retrieving more rows from a result set. +//! +//! Based on Go driver wire format. The FETCH request uses absolute row positions +//! — the client specifies which row to start from, and the server returns a batch +//! of rows up to a byte budget. +//! +//! Request wire format (after 64-byte Frame header): +//! ```text +//! Offset Size Field +//! 0 20 Reserved (zeros) +//! 20 8 startRow (i64 LE) — starting row index (absolute, 0-based) +//! 28 8 endRow (i64 LE) — ending row index (use i64::MAX for all remaining) +//! 36 2 cursorId (i16 LE) — result set cursor ID +//! 38 4 prefetchBytes (i32 LE) — max bytes to fetch, clamped [32, 65536] +//! ``` +//! +//! Response wire format: +//! ```text +//! Offset Size Field +//! 0 20 Reserved +//! 20 8 updateCount (i64 LE) — total row count in result set +//! 28 4 rsSizeof (i32 LE) — byte size of row data +//! 32 N row data (same format as EXEC_RESPONSE inline rows) +//! ``` + +use bytes::{BufMut, BytesMut}; + +use crate::error::Result; +use dameng_types::encoding::ServerEncoding; + +use super::response::{Column, ExecResponse, Row}; + +/// Default prefetch byte budget for FETCH requests. +pub const DEFAULT_PREFETCH_BYTES: i32 = 8192; + +/// Minimum prefetch byte budget. +pub const MIN_PREFETCH_BYTES: i32 = 32; + +/// Maximum prefetch byte budget. +pub const MAX_PREFETCH_BYTES: i32 = 65536; + +/// Client->Server FETCH message (type 7). +/// +/// Requests the next batch of rows from a previously executed query. +/// Uses absolute row positioning — `start_row` specifies which row to begin +/// fetching from, and the server returns up to `prefetch_bytes` of row data. +#[derive(Debug, Clone)] +pub struct FetchMessage { + /// Starting row index (absolute, 0-based). + pub start_row: i64, + /// Ending row index (use i64::MAX to fetch all remaining). + pub end_row: i64, + /// Result set cursor ID. + pub cursor_id: i16, + /// Maximum bytes to fetch (clamped to [32, 65536]). + pub prefetch_bytes: i32, +} + +impl FetchMessage { + /// Create a new fetch message. + /// + /// # Arguments + /// * `start_row` — The row index to start fetching from (0-based, absolute). + /// * `cursor_id` — The result set cursor ID from the initial query. + /// * `prefetch_bytes` — The maximum bytes to fetch (clamped to [32, 65536]). + pub fn new(start_row: i64, cursor_id: i16, prefetch_bytes: i32) -> Self { + let clamped = prefetch_bytes.clamp(MIN_PREFETCH_BYTES, MAX_PREFETCH_BYTES); + Self { + start_row, + end_row: i64::MAX, // Fetch all remaining rows + cursor_id, + prefetch_bytes: clamped, + } + } + + /// Create a new fetch message requesting from the given row, fetching all remaining. + /// + /// This is the most common case — fetch from a specific row position to the end. + pub fn fetch_from(start_row: i64, cursor_id: i16) -> Self { + Self::new(start_row, cursor_id, DEFAULT_PREFETCH_BYTES) + } + + /// Encode to payload bytes. + /// + /// Wire format: + /// - 20 bytes reserved (zeros) + /// - startRow (i64 LE) + /// - endRow (i64 LE) + /// - cursorId (i16 LE) + /// - prefetchBytes (i32 LE) + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::with_capacity(42); + // 20 bytes reserved + buf.put_bytes(0, 20); + // startRow (i64 LE) + buf.put_i64_le(self.start_row); + // endRow (i64 LE) + buf.put_i64_le(self.end_row); + // cursorId (i16 LE) + buf.put_i16_le(self.cursor_id); + // prefetchBytes (i32 LE) + buf.put_i32_le(self.prefetch_bytes); + buf + } +} + +/// Response from a FETCH request (msg_type=7). +#[derive(Debug, Clone)] +pub struct FetchResponse { + /// Total number of rows in the entire result set. + pub total_row_count: i64, + /// Column metadata (may be empty if already known from initial query). + pub columns: Vec, + /// Row data fetched in this batch. + pub rows: Vec, +} + +impl FetchResponse { + /// Parse a FETCH response from raw payload bytes. + /// + /// Response format: + /// - Offset 0-19: reserved + /// - Offset 20-27: updateCount (i64 LE) — total row count + /// - Offset 28-31: rsSizeof (i32 LE) — byte size of row data + /// - Offset 32+: row data (same format as EXEC_RESPONSE inline rows) + pub fn from_bytes(data: &[u8], server_encoding: ServerEncoding) -> Result { + if data.len() < 32 { + return Err(crate::error::Error::Incomplete); + } + + // updateCount at offset 20 + let total_row_count = i64::from_le_bytes([ + data[20], data[21], data[22], data[23], data[24], data[25], data[26], data[27], + ]); + + // rsSizeof at offset 28 + let rs_sizeof = if data.len() >= 32 { + i32::from_le_bytes([data[28], data[29], data[30], data[31]]) as usize + } else { + 0 + }; + + // Row data starts at offset 32 + let row_data_start = 32; + let row_data_end = (row_data_start + rs_sizeof).min(data.len()); + + if row_data_start >= data.len() || rs_sizeof == 0 { + return Ok(FetchResponse { + total_row_count, + columns: vec![], + rows: vec![], + }); + } + + let row_data = &data[row_data_start..row_data_end]; + + // The row data follows the same inline format as EXEC_RESPONSE. + // Parse it using the ExecResponse parser. + // Guard against parsing garbage: if row_data is all zeros or too short, + // the server returned metadata only (no inline data). + let has_real_data = + row_data.len() > 16 && !row_data.iter().all(|&b| b == 0) && row_data[0] != 0; + + if !has_real_data { + // Server returned a cursor/total count but no inline row data. + // This happens when the cursor_id is invalid or the result is empty. + return Ok(FetchResponse { + total_row_count, + columns: vec![], + rows: vec![], + }); + } + + match ExecResponse::from_bytes(row_data, server_encoding) { + Ok(resp) => Ok(FetchResponse { + total_row_count, + columns: resp.columns, + rows: resp.rows, + }), + Err(_) => { + // If we can't parse the row data as EXEC_RESPONSE format, + // return what we have with empty rows. + Ok(FetchResponse { + total_row_count, + columns: vec![], + rows: vec![], + }) + } + } + } + + /// Check if there are more rows to fetch. + pub fn has_more(&self, current_pos: usize) -> bool { + current_pos < self.total_row_count as usize + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fetch_new() { + let fetch = FetchMessage::new(0, 0, DEFAULT_PREFETCH_BYTES); + assert_eq!(fetch.start_row, 0); + assert_eq!(fetch.end_row, i64::MAX); + assert_eq!(fetch.cursor_id, 0); + assert_eq!(fetch.prefetch_bytes, DEFAULT_PREFETCH_BYTES); + } + + #[test] + fn test_fetch_payload_size() { + let fetch = FetchMessage::new(100, 1, 4096); + let payload = fetch.encode_payload(); + assert_eq!(payload.len(), 42); // 20 + 8 + 8 + 2 + 4 + } + + #[test] + fn test_fetch_encode_decode() { + let fetch = FetchMessage::new(42, 5, 8192); + let payload = fetch.encode_payload(); + + // Verify reserved bytes + assert!(payload[..20].iter().all(|&b| b == 0)); + + // Verify startRow at offset 20 + let start_row = i64::from_le_bytes([ + payload[20], + payload[21], + payload[22], + payload[23], + payload[24], + payload[25], + payload[26], + payload[27], + ]); + assert_eq!(start_row, 42); + + // Verify endRow at offset 28 + let end_row = i64::from_le_bytes([ + payload[28], + payload[29], + payload[30], + payload[31], + payload[32], + payload[33], + payload[34], + payload[35], + ]); + assert_eq!(end_row, i64::MAX); + + // Verify cursorId at offset 36 + let cursor_id = i16::from_le_bytes([payload[36], payload[37]]); + assert_eq!(cursor_id, 5); + + // Verify prefetchBytes at offset 38 + let prefetch_bytes = + i32::from_le_bytes([payload[38], payload[39], payload[40], payload[41]]); + assert_eq!(prefetch_bytes, 8192); + } + + #[test] + fn test_fetch_prefetch_clamp_min() { + let fetch = FetchMessage::new(0, 0, 1); + assert_eq!(fetch.prefetch_bytes, MIN_PREFETCH_BYTES); + } + + #[test] + fn test_fetch_prefetch_clamp_max() { + let fetch = FetchMessage::new(0, 0, i32::MAX); + assert_eq!(fetch.prefetch_bytes, MAX_PREFETCH_BYTES); + } + + #[test] + fn test_fetch_prefetch_normal() { + let fetch = FetchMessage::new(0, 0, 4096); + assert_eq!(fetch.prefetch_bytes, 4096); + } + + #[test] + fn test_fetch_from() { + let fetch = FetchMessage::fetch_from(50, 3); + assert_eq!(fetch.start_row, 50); + assert_eq!(fetch.cursor_id, 3); + assert_eq!(fetch.prefetch_bytes, DEFAULT_PREFETCH_BYTES); + } + + #[test] + fn test_fetch_response_incomplete() { + let data = [0u8; 10]; + let result = FetchResponse::from_bytes(&data, ServerEncoding::Utf8); + assert!(result.is_err()); + } + + #[test] + fn test_fetch_response_empty_data() { + let data = vec![0u8; 42]; + let resp = FetchResponse::from_bytes(&data, ServerEncoding::Utf8).unwrap(); + assert_eq!(resp.total_row_count, 0); + assert!(resp.rows.is_empty()); + } + + #[test] + fn test_fetch_clone() { + let fetch = FetchMessage::new(100, 2, 4096); + let cloned = fetch.clone(); + assert_eq!(cloned.start_row, 100); + assert_eq!(cloned.cursor_id, 2); + assert_eq!(cloned.prefetch_bytes, 4096); + } + + #[test] + fn test_has_more() { + let resp = FetchResponse { + total_row_count: 1000, + columns: vec![], + rows: vec![], + }; + assert!(resp.has_more(0)); + assert!(resp.has_more(999)); + assert!(!resp.has_more(1000)); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/isolation.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/isolation.rs new file mode 100644 index 000000000..ca6565c50 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/isolation.rs @@ -0,0 +1,180 @@ +//! SET_ISOLATION message (type 52) for setting transaction isolation level. +//! +//! DM server protocol values (verified against Go driver dm_go/m.go g2dbIsoLevel): +//! - 0: Read Uncommitted +//! - 1: Read Committed +//! - 2: Repeatable Read +//! - 3: Serializable + +use bytes::{BufMut, BytesMut}; + +use crate::frame::FRAME_HEADER_SIZE; +use crate::message::SET_ISOLATION; + +/// Transaction isolation levels supported by DM. +/// +/// Note: The DM protocol uses 0/1/2/3 for these values, NOT the standard +/// SQL 1/2/4/6 values. The Go driver's `g2dbIsoLevel()` does the same mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IsolationLevel { + /// Read Uncommitted - can read uncommitted changes from other transactions. + ReadUncommitted, + /// Read Committed - only reads committed data. + ReadCommitted, + /// Repeatable Read - guarantees same result for repeated reads. + RepeatableRead, + /// Serializable - complete isolation, transactions run serially. + Serializable, +} + +impl IsolationLevel { + /// Convert to DM protocol value (0/1/2/3). + pub fn to_protocol_value(self) -> i32 { + match self { + IsolationLevel::ReadUncommitted => 0, + IsolationLevel::ReadCommitted => 1, + IsolationLevel::RepeatableRead => 2, + IsolationLevel::Serializable => 3, + } + } + + /// Create from DM protocol value (0/1/2/3). + pub fn from_protocol_value(value: i32) -> Option { + match value { + 0 => Some(IsolationLevel::ReadUncommitted), + 1 => Some(IsolationLevel::ReadCommitted), + 2 => Some(IsolationLevel::RepeatableRead), + 3 => Some(IsolationLevel::Serializable), + _ => None, + } + } +} + +/// SET_ISOLATION message (type 52) to change transaction isolation level. +#[derive(Debug, Clone)] +pub struct SetIsolationMessage { + /// The isolation level to set. + pub level: IsolationLevel, +} + +impl SetIsolationMessage { + /// Create a new SET_ISOLATION message. + pub fn new(level: IsolationLevel) -> Self { + Self { level } + } + + /// Encode to a complete frame (header + no payload). + /// + /// Verified against Go driver (dm_build_828): the isolation level is + /// written **inside** the 64-byte frame header at offset 20 (i32 LE), + /// with body_len=0. No extra payload is sent. + pub fn encode_frame(&self, handle: u32) -> BytesMut { + let mut buf = BytesMut::with_capacity(FRAME_HEADER_SIZE); + buf.put_bytes(0, FRAME_HEADER_SIZE); + + // Offset 0: handle (u32 LE) + buf[0..4].copy_from_slice(&handle.to_le_bytes()); + // Offset 4: msg_type (SET_ISOLATION=52) + buf[4] = SET_ISOLATION; + // Offset 6: body_len (i32 LE) = 0 + // Offset 10: response_code (i32 LE) = 0 + + // Offset 20: isolation_level (i32 LE) — written into reserved area + buf[20..24].copy_from_slice(&self.level.to_protocol_value().to_le_bytes()); + + // Compute XOR checksum at offset 19 (bytes 0..19) + let mut cs: u8 = 0; + for i in 0..19 { + cs ^= buf[i]; + } + buf[19] = cs; + + buf + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_isolation_level_protocol_values() { + assert_eq!(IsolationLevel::ReadUncommitted.to_protocol_value(), 0); + assert_eq!(IsolationLevel::ReadCommitted.to_protocol_value(), 1); + assert_eq!(IsolationLevel::RepeatableRead.to_protocol_value(), 2); + assert_eq!(IsolationLevel::Serializable.to_protocol_value(), 3); + } + + #[test] + fn test_isolation_level_from_protocol() { + assert_eq!( + IsolationLevel::from_protocol_value(0), + Some(IsolationLevel::ReadUncommitted) + ); + assert_eq!( + IsolationLevel::from_protocol_value(1), + Some(IsolationLevel::ReadCommitted) + ); + assert_eq!( + IsolationLevel::from_protocol_value(2), + Some(IsolationLevel::RepeatableRead) + ); + assert_eq!( + IsolationLevel::from_protocol_value(3), + Some(IsolationLevel::Serializable) + ); + assert_eq!(IsolationLevel::from_protocol_value(99), None); + } + + #[test] + fn test_set_isolation_encode() { + let msg = SetIsolationMessage::new(IsolationLevel::ReadCommitted); + let frame = msg.encode_frame(0); + assert_eq!(frame.len(), FRAME_HEADER_SIZE); + // isolation_level at offset 20 + assert_eq!( + i32::from_le_bytes([frame[20], frame[21], frame[22], frame[23]]), + 1 // ReadCommitted = 1 in protocol + ); + // msg_type at offset 4 + assert_eq!(frame[4], SET_ISOLATION); + } + + #[test] + fn test_set_isolation_all_levels() { + for level in [ + IsolationLevel::ReadUncommitted, + IsolationLevel::ReadCommitted, + IsolationLevel::RepeatableRead, + IsolationLevel::Serializable, + ] { + let msg = SetIsolationMessage::new(level); + let frame = msg.encode_frame(7); + assert_eq!(frame.len(), FRAME_HEADER_SIZE); + assert_eq!(frame[4], SET_ISOLATION); + assert_eq!( + i32::from_le_bytes([frame[20], frame[21], frame[22], frame[23]]), + level.to_protocol_value() + ); + // verify handle + assert_eq!( + u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]), + 7 + ); + } + } + + #[test] + fn test_isolation_roundtrip() { + for level in [ + IsolationLevel::ReadUncommitted, + IsolationLevel::ReadCommitted, + IsolationLevel::RepeatableRead, + IsolationLevel::Serializable, + ] { + let pv = level.to_protocol_value(); + let recovered = IsolationLevel::from_protocol_value(pv).unwrap(); + assert_eq!(level, recovered, "roundtrip failed for {:?}", level); + } + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/lob.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/lob.rs new file mode 100644 index 000000000..3e7bf6f93 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/lob.rs @@ -0,0 +1,575 @@ +//! LOBREAD protocol messages (msg_type=32). +//! +//! Reverse-engineered from the Go driver (dm_go/zq.go: dm_build_676 / dm_build_680). +//! Used to read out-of-row CLOB/BLOB data in chunks. +//! +//! ## Request format (LOBREAD, msg_type=32): +//! +//! | Field | Type | Size | Description | +//! |-------------|--------|------|---------------------------------------| +//! | lobFlag | byte | 1 | 0 = BLOB, 1 = CLOB | +//! | tabId | i32 LE | 4 | Table ID from column metadata | +//! | colId | i16 LE | 2 | Column ID from column metadata | +//! | blobId | i64 LE | 8 | LOB identifier from NBLOB_HEAD | +//! | groupId | i16 LE | 2 | LOB storage group ID | +//! | fileId | i16 LE | 2 | LOB storage file ID | +//! | pageNo | i32 LE | 4 | LOB starting page number | +//! | curFileId | i16 LE | 2 | Current file ID (tracking cursor) | +//! | curPageNo | i32 LE | 4 | Current page number (tracking cursor) | +//! | totalOffset | i32 LE | 4 | Accumulated offset so far | +//! | position | i32 LE | 4 | Read start position (0-based) | +//! | length | i32 LE | 4 | Number of bytes/chars to read | +//! +//! Extended section (if NewLobFlag is set on server): +//! | Field | Type | Size | Description | +//! |-----------|--------|------|---------------------------| +//! | rowId | i64 LE | 8 | Row ID from NBLOB_HEAD | +//! | exGroupId | i16 LE | 2 | Extended group ID | +//! | exFileId | i16 LE | 2 | Extended file ID | +//! | exPageNo | i32 LE | 4 | Extended page number | +//! +//! Total base payload: 41 bytes +//! Extended payload: 57 bytes + +use bytes::{BufMut, BytesMut}; +use dameng_types::LobLocator; + +/// LOBREAD request message. +/// +/// Encodes a request to read a chunk of LOB data from the server. +/// The server responds with the data chunk and updated cursor position. +#[derive(Debug, Clone)] +pub struct LobReadMessage { + /// LOB locator containing all necessary metadata. + locator: LobLocator, + /// Read start position (0-based byte/character offset). + position: i32, + /// Number of bytes (BLOB) or characters (CLOB) to read. + length: i32, + /// Whether the server supports the extended LOB format (NewLobFlag). + new_lob_flag: bool, +} + +impl LobReadMessage { + /// Create a new LOBREAD message. + /// + /// # Arguments + /// * `locator` - The LOB locator from the query result + /// * `position` - 0-based read offset + /// * `length` - Number of bytes/chars to read + /// * `new_lob_flag` - Whether server supports extended LOB format + pub fn new(locator: LobLocator, position: i32, length: i32, new_lob_flag: bool) -> Self { + Self { + locator, + position, + length, + new_lob_flag, + } + } + + /// Encode this message into a payload suitable for msg_type=32. + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::with_capacity(64); + + // lobFlag: 0 = BLOB, 1 = CLOB + buf.put_u8(self.locator.lob_flag()); + + // tabId (i32 LE) + buf.put_i32_le(self.locator.tab_id); + + // colId (i16 LE) + buf.put_i16_le(self.locator.col_id); + + // blobId (i64 LE) + buf.put_i64_le(self.locator.blob_id()); + + // groupId (i16 LE) + buf.put_i16_le(self.locator.group_id()); + + // fileId (i16 LE) + buf.put_i16_le(self.locator.file_id()); + + // pageNo (i32 LE) + buf.put_i32_le(self.locator.page_no()); + + // curFileId (i16 LE) — use cursor state for subsequent reads + buf.put_i16_le(self.locator.cur_file_id); + + // curPageNo (i32 LE) — use cursor state for subsequent reads + buf.put_i32_le(self.locator.cur_page_no); + + // totalOffset (i32 LE) — accumulated offset from cursor + buf.put_i32_le(self.locator.total_offset); + + // position (i32 LE) — read start position + buf.put_i32_le(self.position); + + // length (i32 LE) — bytes/chars to read + buf.put_i32_le(self.length); + + // Extended section (if NewLobFlag) + if self.new_lob_flag { + // rowId (i64 LE) + buf.put_i64_le(self.locator.row_id()); + + // exGroupId (i16 LE) + buf.put_i16_le(self.locator.ex_group_id()); + + // exFileId (i16 LE) + buf.put_i16_le(self.locator.ex_file_id()); + + // exPageNo (i32 LE) + buf.put_i32_le(self.locator.ex_page_no()); + } + + buf + } +} + +/// Response from a LOBREAD request. +/// +/// Contains the data chunk and updated cursor state for subsequent reads. +#[derive(Debug, Clone)] +pub struct LobReadResponse { + /// The LOB data chunk returned by the server. + pub data: Vec, + /// Character length for CLOB (actual UTF-8 char count). -1 if unknown. + pub char_len: i64, + /// True if there are no more bytes to read (EOF). + pub read_over: bool, + /// Updated current file ID (for subsequent reads). + pub cur_file_id: i16, + /// Updated current page number (for subsequent reads). + pub cur_page_no: i32, + /// Updated total offset (for subsequent reads). + pub total_offset: i32, +} + +impl LobReadResponse { + /// Parse a LOBREAD response from raw payload bytes. + /// + /// Response format: + /// - readOver (1 byte) + /// - dataLen (i32 LE) + /// - curFileId (i16 LE) + /// - curPageNo (i32 LE) + /// - totalOffset (i32 LE) + /// - data (dataLen bytes) + /// - [optional] charLen (i32 LE) if remaining bytes > 0 + pub fn from_bytes(data: &[u8]) -> crate::error::Result { + if data.len() < 1 { + return Err(crate::error::Error::Incomplete); + } + + let mut offset = 0; + + // readOver (1 byte) + let read_over = data[offset] == 1; + offset += 1; + + // dataLen (i32 LE) + if offset + 4 > data.len() { + return Err(crate::error::Error::Incomplete); + } + let data_len = i32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + offset += 4; + + if data_len <= 0 { + return Ok(Self { + data: vec![], + char_len: -1, + read_over, + cur_file_id: 0, + cur_page_no: 0, + total_offset: 0, + }); + } + + // curFileId (i16 LE) + if offset + 2 > data.len() { + return Err(crate::error::Error::Incomplete); + } + let cur_file_id = i16::from_le_bytes([data[offset], data[offset + 1]]); + offset += 2; + + // curPageNo (i32 LE) + if offset + 4 > data.len() { + return Err(crate::error::Error::Incomplete); + } + let cur_page_no = i32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + offset += 4; + + // totalOffset (i32 LE) + if offset + 4 > data.len() { + return Err(crate::error::Error::Incomplete); + } + let total_offset = i32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + offset += 4; + + // data (dataLen bytes) + let data_len = data_len as usize; + if offset + data_len > data.len() { + return Err(crate::error::Error::Incomplete); + } + let response_data = data[offset..offset + data_len].to_vec(); + offset += data_len; + + // Optional: charLen (i32 LE) if there are remaining bytes + let mut char_len: i64 = -1; + if offset + 4 <= data.len() { + // Check if there are extra bytes after data + let remaining = data.len() - offset; + if remaining >= 4 { + char_len = i64::from(i32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ])); + } + } + + Ok(Self { + data: response_data, + char_len, + read_over, + cur_file_id, + cur_page_no, + total_offset, + }) + } +} + +/// LOBFREE request message (msg_type=29). +/// +/// Used to release a LOB locator on the server. +#[derive(Debug, Clone)] +pub struct LobFreeMessage { + /// LOB locator to free. + locator: LobLocator, +} + +impl LobFreeMessage { + /// Create a new LOBFREE message. + pub fn new(locator: LobLocator) -> Self { + Self { locator } + } + + /// Encode this message into a payload. + /// + /// Format: + /// - lobFlag (1 byte) + /// - blobId (i64 LE) + /// - groupId (i16 LE) + /// - fileId (i16 LE) + /// - pageNo (i32 LE) + /// - [if NewLobFlag] tabId (i32 LE), colId (i16 LE), rowId (i64 LE), + /// exGroupId (i16 LE), exFileId (i16 LE), exPageNo (i32 LE) + pub fn encode_payload(&self, new_lob_flag: bool) -> BytesMut { + let mut buf = BytesMut::with_capacity(48); + + buf.put_u8(self.locator.lob_flag()); + buf.put_i64_le(self.locator.blob_id()); + buf.put_i16_le(self.locator.group_id()); + buf.put_i16_le(self.locator.file_id()); + buf.put_i32_le(self.locator.page_no()); + + if new_lob_flag { + buf.put_i32_le(self.locator.tab_id); + buf.put_i16_le(self.locator.col_id); + buf.put_i64_le(self.locator.row_id()); + buf.put_i16_le(self.locator.ex_group_id()); + buf.put_i16_le(self.locator.ex_file_id()); + buf.put_i32_le(self.locator.ex_page_no()); + } + + buf + } +} + +/// LOBGETLEN request message (msg_type=31). +/// +/// Used to get the length of a LOB. +#[derive(Debug, Clone)] +pub struct LobGetLenMessage { + /// LOB locator. + locator: LobLocator, +} + +impl LobGetLenMessage { + /// Create a new LOBGETLEN message. + pub fn new(locator: LobLocator) -> Self { + Self { locator } + } + + /// Encode this message. + /// + /// Format: + /// - lobFlag (1 byte) + /// - blobId (i64 LE) + /// - groupId (i16 LE) + /// - fileId (i16 LE) + /// - pageNo (i32 LE) + /// - tabId (i32 LE) + /// - colId (i16 LE) + /// - rowId (i64 LE) + /// - [if NewLobFlag] exGroupId (i16 LE), exFileId (i16 LE), exPageNo (i32 LE) + pub fn encode_payload(&self, new_lob_flag: bool) -> BytesMut { + let mut buf = BytesMut::with_capacity(48); + + buf.put_u8(self.locator.lob_flag()); + buf.put_i64_le(self.locator.blob_id()); + buf.put_i16_le(self.locator.group_id()); + buf.put_i16_le(self.locator.file_id()); + buf.put_i32_le(self.locator.page_no()); + buf.put_i32_le(self.locator.tab_id); + buf.put_i16_le(self.locator.col_id); + buf.put_i64_le(self.locator.row_id()); + + if new_lob_flag { + buf.put_i16_le(self.locator.ex_group_id()); + buf.put_i16_le(self.locator.ex_file_id()); + buf.put_i32_le(self.locator.ex_page_no()); + } + + buf + } +} + +/// Response from a LOBGETLEN request. +#[derive(Debug, Clone)] +pub struct LobGetLenResponse { + /// Length in bytes (BLOB) or characters (CLOB). + pub length: i64, + /// New blob ID from server (if server updated it). + /// Used to refresh the locator for subsequent reads. + pub new_blob_id: Option, +} + +impl LobGetLenResponse { + /// Parse LOBGETLEN response from raw payload. + /// + /// Response format (matching Go driver dm_build_714.dm_build_425): + /// - length (i32 LE) — LOB length + /// - newBlobId (i64 LE) — updated blob ID from server (DDWORD) + pub fn from_bytes(data: &[u8]) -> crate::error::Result { + if data.len() < 4 { + return Err(crate::error::Error::Incomplete); + } + let length = i64::from(i32::from_le_bytes([data[0], data[1], data[2], data[3]])); + + // Parse newBlobId (DDWORD = i64 LE) if available + let new_blob_id = if data.len() >= 12 { + let blob_id = i64::from_le_bytes([ + data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11], + ]); + Some(blob_id) + } else { + None + }; + + Ok(Self { + length, + new_blob_id, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_locator() -> LobLocator { + // Build a minimal NBLOB_HEAD with in_row=0x02 + let mut raw = vec![0u8; 43]; + raw[0] = 0x02; // in_row = out-of-row + // blob_id at offset 1 + raw[1..9].copy_from_slice(&42i64.to_le_bytes()); + // group_id at offset 13 + raw[13..15].copy_from_slice(&1i16.to_le_bytes()); + // file_id at offset 15 + raw[15..17].copy_from_slice(&2i16.to_le_bytes()); + // page_no at offset 17 + raw[17..21].copy_from_slice(&100i32.to_le_bytes()); + // tab_id at offset 21 + raw[21..25].copy_from_slice(&1000i32.to_le_bytes()); + // col_id at offset 25 + raw[25..27].copy_from_slice(&3i16.to_le_bytes()); + // row_id at offset 27 + raw[27..35].copy_from_slice(&999i64.to_le_bytes()); + // exGroupId at offset 35 + raw[35..37].copy_from_slice(&5i16.to_le_bytes()); + // exFileId at offset 37 + raw[37..39].copy_from_slice(&6i16.to_le_bytes()); + // exPageNo at offset 39 + raw[39..43].copy_from_slice(&200i32.to_le_bytes()); + + LobLocator::from_nblob_head(raw, true) + } + + #[test] + fn test_lob_locator_parsing() { + let mut loc = make_test_locator(); + assert_eq!(loc.blob_id(), 42); + assert_eq!(loc.group_id(), 1); + assert_eq!(loc.file_id(), 2); + assert_eq!(loc.page_no(), 100); + assert_eq!(loc.tab_id, 1000); + assert_eq!(loc.col_id, 3); + assert_eq!(loc.row_id(), 999); + assert_eq!(loc.ex_group_id(), 5); + assert_eq!(loc.ex_file_id(), 6); + assert_eq!(loc.ex_page_no(), 200); + assert!(loc.has_extended()); + assert!(loc.is_clob); + assert_eq!(loc.lob_flag(), 1); + // Cursor starts at 0 + assert_eq!(loc.cur_file_id, 0); + assert_eq!(loc.cur_page_no, 0); + assert_eq!(loc.total_offset, 0); + // After init_cursor(), cursor = file_id/page_no + loc.init_cursor(); + assert_eq!(loc.cur_file_id, 2); + assert_eq!(loc.cur_page_no, 100); + assert_eq!(loc.total_offset, 0); + } + + #[test] + fn test_lob_read_encode() { + let mut loc = make_test_locator(); + loc.init_cursor(); + let msg = LobReadMessage::new(loc, 0, 1024, true); + let payload = msg.encode_payload(); + // Base (41) + extended (16) = 57 bytes + assert_eq!(payload.len(), 57); + // First byte is lobFlag + assert_eq!(payload[0], 1); // CLOB + } + + #[test] + fn test_lob_read_encode_no_extended() { + let loc = make_test_locator(); + let msg = LobReadMessage::new(loc, 100, 512, false); + let payload = msg.encode_payload(); + assert_eq!(payload.len(), 41); + } + + #[test] + fn test_lob_read_response_parse() { + // Build a minimal LOBREAD response: + // readOver=0, dataLen=5, curFileId=2, curPageNo=101, totalOffset=5, data="HELLO" + let mut resp_data = vec![0u8; 25]; + resp_data[0] = 0; // readOver = false + resp_data[1..5].copy_from_slice(&5i32.to_le_bytes()); // dataLen + resp_data[5..7].copy_from_slice(&2i16.to_le_bytes()); // curFileId + resp_data[7..11].copy_from_slice(&101i32.to_le_bytes()); // curPageNo + resp_data[11..15].copy_from_slice(&5i32.to_le_bytes()); // totalOffset + resp_data[15..20].copy_from_slice(b"HELLO"); + let resp = LobReadResponse::from_bytes(&resp_data).unwrap(); + assert!(!resp.read_over); + assert_eq!(resp.data, b"HELLO"); + assert_eq!(resp.cur_file_id, 2); + assert_eq!(resp.cur_page_no, 101); + assert_eq!(resp.total_offset, 5); + } + + #[test] + fn test_lob_read_response_eof() { + // readOver=1, dataLen=0 + let resp_data = vec![1u8, 0, 0, 0, 0]; + let resp = LobReadResponse::from_bytes(&resp_data).unwrap(); + assert!(resp.read_over); + assert!(resp.data.is_empty()); + } + + #[test] + fn test_lob_cursor_update() { + let mut loc = make_test_locator(); + loc.init_cursor(); + assert_eq!(loc.cur_file_id, 2); + assert_eq!(loc.cur_page_no, 100); + + // Simulate response from first LOBREAD + loc.update_cursor(2, 101, 1024); + assert_eq!(loc.cur_file_id, 2); + assert_eq!(loc.cur_page_no, 101); + assert_eq!(loc.total_offset, 1024); + + // Simulate response from second LOBREAD + loc.update_cursor(3, 200, 2048); + assert_eq!(loc.cur_file_id, 3); + assert_eq!(loc.cur_page_no, 200); + assert_eq!(loc.total_offset, 2048); + } + + #[test] + fn test_lob_free_encode() { + let loc = make_test_locator(); + let free_msg = LobFreeMessage::new(loc); + let payload = free_msg.encode_payload(true); + // lobFlag(1) + blobId(8) + groupId(2) + fileId(2) + pageNo(4) = 17 + // + tabId(4) + colId(2) + rowId(8) + exGroupId(2) + exFileId(2) + exPageNo(4) = 22 + // Total = 39 + assert_eq!(payload.len(), 39); + assert_eq!(payload[0], 1); // CLOB + } + + #[test] + fn test_lob_free_encode_no_extended() { + let loc = make_test_locator(); + let free_msg = LobFreeMessage::new(loc); + let payload = free_msg.encode_payload(false); + // lobFlag(1) + blobId(8) + groupId(2) + fileId(2) + pageNo(4) = 17 + assert_eq!(payload.len(), 17); + } + + #[test] + fn test_lob_getlen_encode() { + let loc = make_test_locator(); + let getlen_msg = LobGetLenMessage::new(loc); + let payload = getlen_msg.encode_payload(true); + // lobFlag(1) + blobId(8) + groupId(2) + fileId(2) + pageNo(4) + tabId(4) + colId(2) + rowId(8) = 31 + // + exGroupId(2) + exFileId(2) + exPageNo(4) = 8 + // Total = 39 + assert_eq!(payload.len(), 39); + } + + #[test] + fn test_lob_getlen_response_parse() { + let resp_data = 2048i32.to_le_bytes(); + let resp = LobGetLenResponse::from_bytes(&resp_data).unwrap(); + assert_eq!(resp.length, 2048); + } + + #[test] + fn test_lob_read_response_with_char_len() { + // readOver=0, dataLen=5, curFileId=2, curPageNo=101, totalOffset=5, data="HELLO", charLen=5 + let mut resp_data = vec![0u8; 29]; + resp_data[0] = 0; // readOver = false + resp_data[1..5].copy_from_slice(&5i32.to_le_bytes()); // dataLen + resp_data[5..7].copy_from_slice(&2i16.to_le_bytes()); // curFileId + resp_data[7..11].copy_from_slice(&101i32.to_le_bytes()); // curPageNo + resp_data[11..15].copy_from_slice(&5i32.to_le_bytes()); // totalOffset + resp_data[15..20].copy_from_slice(b"HELLO"); + resp_data[20..24].copy_from_slice(&5i32.to_le_bytes()); // charLen + let resp = LobReadResponse::from_bytes(&resp_data).unwrap(); + assert!(!resp.read_over); + assert_eq!(resp.data, b"HELLO"); + assert_eq!(resp.char_len, 5); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/lob_bind.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/lob_bind.rs new file mode 100644 index 000000000..f6b0b3eac --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/lob_bind.rs @@ -0,0 +1,260 @@ +//! LOB data streaming message for binding large CLOB/BLOB parameters. +//! +//! When a CLOB/BLOB parameter exceeds 2048 bytes (DM_OFF_ROW_THRESHOLD), +//! the data is streamed to the server in chunks BEFORE the bind execute +//! message. This uses message type 14. +//! +//! Wire format (per Go driver `dm_build_811`): +//! ```text +//! Offset Size Field +//! 0 1 msg_type (14) +//! 1 2 param_index (i16 LE) - which parameter this chunk belongs to +//! 3 4 data_length (i32 LE) - length of data in this chunk +//! 7 N data bytes +//! ``` +//! +//! After all chunks are sent for all off-row parameters, the bind execute +//! message is sent with empty placeholders for those parameters. + +use bytes::{BufMut, BytesMut}; + +/// DM off-row threshold — LOBs larger than this use streaming. +pub const DM_OFF_ROW_THRESHOLD: usize = 2048; + +/// Maximum chunk size for streaming LOB data to the server. +pub const DM_LOB_CHUNK_SIZE: usize = 16000; + +/// Message type for LOB data streaming. +pub const DM_LOB_DATA_MSG_TYPE: u8 = 14; + +/// A single chunk of LOB data to stream to the server. +/// +/// This is sent as msg_type=14 with the parameter index and chunk data. +#[derive(Debug, Clone)] +pub struct LobDataMessage { + /// Parameter index (0-based) this chunk belongs to. + pub param_index: i16, + /// The data chunk bytes. + pub data: Vec, +} + +impl LobDataMessage { + /// Create a new LOB data chunk message. + pub fn new(param_index: i16, data: Vec) -> Self { + Self { param_index, data } + } + + /// Encode to payload bytes (without frame header). + /// + /// Matches the Go driver's dm_build_811 message format: + /// - 20 bytes reserved (zeros) + /// - param_index (i16 LE, USINT) at offset 20 + /// - data_length (i32 LE, ULINT) at offset 22 + /// - if NewLobFlag: -1 (i32 LE, ULINT) at offset 26 + /// - data bytes starting at offset 28 (or 26 if no NewLobFlag) + pub fn encode_payload(&self, new_lob_flag: bool) -> BytesMut { + let mut buf = BytesMut::new(); + + // 20 bytes reserved (matching Go's Dm_build_810 = Dm_build_327 = 20) + buf.put_bytes(0, 20); + + // param_index (USINT = i16 LE) at offset 20 + buf.put_i16_le(self.param_index); + + // data_length (ULINT = i32 LE) at offset 22 + buf.put_i32_le(self.data.len() as i32); + + // if NewLobFlag: write -1 (ULINT) at offset 26 + if new_lob_flag { + buf.put_i32_le(-1); + } + + // data bytes + buf.put_slice(&self.data); + + buf + } +} + +/// Split LOB data into chunks for streaming. +/// +/// Returns a list of chunks, each at most DM_LOB_CHUNK_SIZE bytes. +pub fn split_lob_data(data: &[u8]) -> Vec> { + let mut chunks = Vec::new(); + let mut start = 0; + while start < data.len() { + let end = (start + DM_LOB_CHUNK_SIZE).min(data.len()); + chunks.push(data[start..end].to_vec()); + start = end; + } + chunks +} + +/// Check if a LOB value should use off-row streaming. +/// +/// CLOB/BLOB types with data > 2048 bytes use the off-row protocol. +/// DM type codes: CLOB=14, BLOB=13. +pub fn is_off_row(dtype: i32, length: usize) -> bool { + let is_lob = dtype == 13 || dtype == 14; + is_lob && length > DM_OFF_ROW_THRESHOLD +} + +/// Encode a CLOB value to bytes for binding. +/// +/// CLOB values are UTF-8 encoded text. +pub fn encode_clob_value(text: &str) -> Vec { + text.as_bytes().to_vec() +} + +/// Encode a BLOB value from hex string for binding. +/// +/// BLOB values are raw bytes. If provided as hex, decode them. +pub fn encode_blob_from_hex(hex_str: &str) -> Vec { + // Simple hex decode (trim "0x" prefix if present) + let hex = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let mut result = Vec::with_capacity(hex.len() / 2); + for i in (0..hex.len()).step_by(2) { + if i + 1 < hex.len() { + if let Ok(byte) = u8::from_str_radix(&hex[i..i + 2], 16) { + result.push(byte); + } + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_off_row_clob_large() { + assert!(is_off_row(14, 3000)); // CLOB > 2048 + } + + #[test] + fn test_is_off_row_clob_small() { + assert!(!is_off_row(14, 100)); // CLOB <= 2048 + } + + #[test] + fn test_is_off_row_blob_large() { + assert!(is_off_row(13, 5000)); // BLOB > 2048 + } + + #[test] + fn test_is_off_row_blob_small() { + assert!(!is_off_row(13, 500)); // BLOB <= 2048 + } + + #[test] + fn test_is_off_row_non_lob() { + assert!(!is_off_row(4, 10000)); // INT never off-row + } + + #[test] + fn test_is_off_row_boundary() { + assert!(!is_off_row(14, 2048)); // exactly at threshold = inline + assert!(is_off_row(14, 2049)); // 1 byte over = off-row + } + + #[test] + fn test_split_lob_data_small() { + let data = vec![1u8; 100]; + let chunks = split_lob_data(&data); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].len(), 100); + } + + #[test] + fn test_split_lob_data_single_chunk() { + let data = vec![1u8; DM_LOB_CHUNK_SIZE]; + let chunks = split_lob_data(&data); + assert_eq!(chunks.len(), 1); + } + + #[test] + fn test_split_lob_data_multiple_chunks() { + let data = vec![1u8; 50000]; + let chunks = split_lob_data(&data); + assert_eq!(chunks.len(), 4); // ceil(50000/16000) + assert_eq!(chunks[0].len(), 16000); + assert_eq!(chunks[1].len(), 16000); + assert_eq!(chunks[2].len(), 16000); + assert_eq!(chunks[3].len(), 2000); + } + + #[test] + fn test_split_lob_data_empty() { + let data: Vec = vec![]; + let chunks = split_lob_data(&data); + assert!(chunks.is_empty()); + } + + #[test] + fn test_lob_data_message_encode_no_new_flag() { + let msg = LobDataMessage::new(0, vec![1, 2, 3, 4, 5]); + let payload = msg.encode_payload(false); + // reserved(20) + param_index(2) + data_len(4) + data(5) = 31 + assert_eq!(payload.len(), 31); + assert!(payload[..20].iter().all(|&b| b == 0)); + assert_eq!(i16::from_le_bytes([payload[20], payload[21]]), 0); + assert_eq!( + i32::from_le_bytes([payload[22], payload[23], payload[24], payload[25]]), + 5 + ); + assert_eq!(&payload[26..], &[1, 2, 3, 4, 5]); + } + + #[test] + fn test_lob_data_message_encode_with_new_flag() { + let msg = LobDataMessage::new(2, vec![1, 2, 3, 4, 5]); + let payload = msg.encode_payload(true); + // reserved(20) + param_index(2) + data_len(4) + new_flag_marker(4) + data(5) = 35 + assert_eq!(payload.len(), 35); + assert!(payload[..20].iter().all(|&b| b == 0)); + assert_eq!(i16::from_le_bytes([payload[20], payload[21]]), 2); + assert_eq!( + i32::from_le_bytes([payload[22], payload[23], payload[24], payload[25]]), + 5 + ); + // NewLobFlag marker: -1 + assert_eq!( + i32::from_le_bytes([payload[26], payload[27], payload[28], payload[29]]), + -1 + ); + assert_eq!(&payload[30..], &[1, 2, 3, 4, 5]); + } + + #[test] + fn test_encode_clob_value() { + let encoded = encode_clob_value("hello"); + assert_eq!(encoded, b"hello"); + } + + #[test] + fn test_encode_clob_value_unicode() { + let encoded = encode_clob_value("你好"); + // UTF-8 bytes for 你好 + assert_eq!(encoded.len(), 6); + } + + #[test] + fn test_encode_blob_from_hex() { + let blob = encode_blob_from_hex("48656c6c6f"); + assert_eq!(blob, b"Hello"); + } + + #[test] + fn test_encode_blob_from_hex_with_prefix() { + let blob = encode_blob_from_hex("0x48656c6c6f"); + assert_eq!(blob, b"Hello"); + } + + #[test] + fn test_constants() { + assert_eq!(DM_OFF_ROW_THRESHOLD, 2048); + assert_eq!(DM_LOB_CHUNK_SIZE, 16000); + assert_eq!(DM_LOB_DATA_MSG_TYPE, 14); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/login.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/login.rs new file mode 100644 index 000000000..99850476c --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/login.rs @@ -0,0 +1,306 @@ +//! LOGIN message (type 1) and LOGIN_RESPONSE (type 163). +//! +//! Wire format reverse-engineered from captured traffic of the official +//! Python dmPython driver via proxy (see proxy_capture.log). + +use bytes::{BufMut, BytesMut}; + +use crate::error::Result; + +/// Client->Server LOGIN message (type 1). +/// +/// Payload layout (from capture): +/// ```text +/// Offset Size Field +/// 0 4 Encrypted username length (i32 LE) +/// 4 N Encrypted username bytes (XOR with challenge) +/// 4+N 4 Encrypted password length (i32 LE) +/// 4+N+4 M Encrypted password bytes (XOR with challenge) +/// 4+N+4+M 4 Separator (4 bytes of zeros) +/// ... 4 OS name length (i32 LE) +/// ... K OS name bytes (plaintext) +/// ... 4 Hostname length (i32 LE) +/// ... L Hostname bytes + null terminator +/// ``` +#[derive(Debug, Clone)] +pub struct LoginMessage { + pub username: String, + pub password: String, + pub hostname: String, + pub os_name: String, +} + +impl LoginMessage { + pub fn new(username: &str, password: &str, hostname: &str) -> Self { + Self { + username: username.to_string(), + password: password.to_string(), + hostname: hostname.to_string(), + os_name: format!("{} {}", std::env::consts::FAMILY, std::env::consts::OS), + } + } + + pub fn encode_payload(&self, challenge: &[u8]) -> BytesMut { + let mut buf = BytesMut::with_capacity(128); + + // Encrypted username (i32 length + XOR-encrypted bytes) + let un_len = self.username.len(); + buf.put_i32_le(un_len as i32); + for i in 0..un_len { + let key_byte = if !challenge.is_empty() { + challenge[i % challenge.len()] + } else { + 0 + }; + buf.put_u8(self.username.as_bytes()[i] ^ key_byte); + } + + // Encrypted password (i32 length + XOR-encrypted bytes) + let pw_len = self.password.len(); + buf.put_i32_le(pw_len as i32); + for i in 0..pw_len { + let key_byte = if !challenge.is_empty() { + challenge[i % challenge.len()] + } else { + 0 + }; + buf.put_u8(self.password.as_bytes()[i] ^ key_byte); + } + + // Separator (4 bytes of zeros) + buf.put_bytes(0, 4); + + // OS name (i32 length + plaintext bytes) + let os_bytes = self.os_name.as_bytes(); + buf.put_i32_le(os_bytes.len() as i32); + buf.put_slice(os_bytes); + + // Hostname + null terminator (i32 length + plaintext bytes + null) + let host_bytes = self.hostname.as_bytes(); + buf.put_i32_le(host_bytes.len() as i32); + buf.put_slice(host_bytes); + buf.put_u8(0); + + buf + } +} + +/// Server->Client LOGIN_RESPONSE message (type 163). +/// +/// Wire format from capture: +/// ```text +/// Offset Size Field +/// 0 16 Reserved (zeros) +/// 16 4 Server name length (i32 LE) +/// 20 N Server name string +/// 20+N 4 Authenticated username length (i32 LE) +/// 20+N+4 M Authenticated username string +/// 20+N+4+M 4 Client IP length (i32 LE) +/// ... K Client IP string +/// ... 4 Login datetime length (i32 LE) +/// ... L Login datetime string +/// ... 4 Session flags +/// ... 4 More flags +/// ... var Database name length + string +/// ``` +#[derive(Debug, Clone)] +pub struct LoginResponse { + pub session_id: u32, + pub encoding: u8, + pub server_status: u8, + pub server_name: String, + pub username: String, + pub client_ip: String, + pub login_datetime: String, + pub db_name: String, +} + +impl LoginResponse { + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() < 0x50 { + return Err(crate::error::Error::Incomplete); + } + + // Session ID at offset 2 + let session_id = u32::from_le_bytes([data[2], data[3], data[4], data[5]]); + + let encoding = match data[0x0A] { + 0 => 0u8, // GB18030 + 1 => 1u8, // UTF-8 + 2 => 2u8, // EUC-KR + _ => 0u8, // default to GB18030 (matching DM Go driver) + }; + + let server_status = data[0x0E]; + + // Server name at offset 0x10 (16): i32 length + string + let sn_len = u32::from_le_bytes([data[0x10], data[0x11], data[0x12], data[0x13]]) as usize; + let sn_start = 0x14; + let sn_end = (sn_start + sn_len).min(data.len()); + let server_name = String::from_utf8_lossy(&data[sn_start..sn_end]) + .trim_matches('\0') + .to_string(); + + // Authenticated username after server name + let un_offset = sn_start + sn_len; + let mut username = String::new(); + let mut client_ip = String::new(); + let mut login_datetime = String::new(); + let mut db_name = String::new(); + + if data.len() > un_offset + 4 { + let un_len = u32::from_le_bytes([ + data[un_offset], + data.get(un_offset + 1).copied().unwrap_or(0), + data.get(un_offset + 2).copied().unwrap_or(0), + data.get(un_offset + 3).copied().unwrap_or(0), + ]) as usize; + let un_start = un_offset + 4; + if un_len > 0 && data.len() > un_start + un_len { + username = String::from_utf8_lossy(&data[un_start..un_start + un_len]).to_string(); + } + + // Client IP + let ip_offset = un_start + un_len; + if data.len() > ip_offset + 4 { + let ip_len = u32::from_le_bytes([ + data[ip_offset], + data.get(ip_offset + 1).copied().unwrap_or(0), + data.get(ip_offset + 2).copied().unwrap_or(0), + data.get(ip_offset + 3).copied().unwrap_or(0), + ]) as usize; + let ip_start = ip_offset + 4; + if ip_len > 0 && data.len() > ip_start + ip_len { + client_ip = + String::from_utf8_lossy(&data[ip_start..ip_start + ip_len]).to_string(); + } + + // Login datetime + let dt_offset = ip_start + ip_len; + if data.len() > dt_offset + 4 { + let dt_len = u32::from_le_bytes([ + data[dt_offset], + data.get(dt_offset + 1).copied().unwrap_or(0), + data.get(dt_offset + 2).copied().unwrap_or(0), + data.get(dt_offset + 3).copied().unwrap_or(0), + ]) as usize; + let dt_start = dt_offset + 4; + if dt_len > 0 && data.len() > dt_start + dt_len { + login_datetime = + String::from_utf8_lossy(&data[dt_start..dt_start + dt_len]).to_string(); + } + + // DB name + let db_offset = dt_start + dt_len; + if data.len() > db_offset + 4 { + let db_len = u32::from_le_bytes([ + data[db_offset], + data.get(db_offset + 1).copied().unwrap_or(0), + data.get(db_offset + 2).copied().unwrap_or(0), + data.get(db_offset + 3).copied().unwrap_or(0), + ]) as usize; + let db_start = db_offset + 4; + if db_len > 0 && data.len() > db_start + db_len { + db_name = String::from_utf8_lossy(&data[db_start..db_start + db_len]) + .to_string(); + } + } + } + } + } + + Ok(Self { + session_id, + encoding, + server_status, + server_name, + username, + client_ip, + login_datetime, + db_name, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_login_new() { + let login = LoginMessage::new("SYSDBA", "SYSDBA", "localhost"); + assert_eq!(login.username, "SYSDBA"); + assert_eq!(login.password, "SYSDBA"); + } + + #[test] + fn test_login_encode_payload_no_challenge() { + let login = LoginMessage::new("SYSDBA", "SYSDBA", "localhost"); + let payload = login.encode_payload(&[]); + // un_len(4) + "SYSDBA"(6) + pw_len(4) + "SYSDBA"(6) + sep(4) + os_len(4) + os + host_len(4) + host(9) + null(1) + let expected = 4 + 6 + 4 + 6 + 4 + 4 + login.os_name.len() + 4 + 9 + 1; + assert_eq!(payload.len(), expected); + // Username should be plaintext without challenge + assert_eq!(&payload[4..10], b"SYSDBA"); + } + + #[test] + fn test_login_xor_encryption() { + let challenge = [0xAAu8; 48]; + let login = LoginMessage::new("AB", "CD", "localhost"); + let payload = login.encode_payload(&challenge); + // 'A' ^ 0xAA = 0x55, 'B' ^ 0xAA = 0xA8 + assert_eq!(payload[4], 0x41 ^ 0xAA); + assert_eq!(payload[5], 0x42 ^ 0xAA); + } + + #[test] + fn test_login_response_from_bytes() { + let mut data = [0u8; 256]; + data[2] = 0x40; + data[3] = 0x1F; // session_id = 0x1F40 + data[0x0A] = 0x01; // UTF-8 + data[0x0E] = 0x01; // server_status + // Server name at 0x10: len + string + let sn = b"DMSERVER"; + data[0x10] = sn.len() as u8; + data[0x14..0x14 + sn.len()].copy_from_slice(sn); + // Username at 0x14 + 8 = 0x1C: len + string + let un = b"SYSDBA"; + data[0x1C] = un.len() as u8; + data[0x20..0x20 + un.len()].copy_from_slice(un); + + let resp = LoginResponse::from_bytes(&data).unwrap(); + assert_eq!(resp.server_name, "DMSERVER"); + assert_eq!(resp.username, "SYSDBA"); + assert_eq!(resp.encoding, 1); + } + + #[test] + fn test_login_response_incomplete() { + let data = [0u8; 32]; + let result = LoginResponse::from_bytes(&data); + assert!(matches!(result, Err(crate::error::Error::Incomplete))); + } + + #[test] + fn test_login_payload_matches_capture() { + // From capture: un_len=6, encrypted_un, pw_len=6, encrypted_pw, sep=4, os_len=8, os, host_len, host, null + let challenge = [0xBBu8; 48]; + let login = LoginMessage::new("SYSDBA", "SYSDBA", "localhost"); + let payload = login.encode_payload(&challenge); + assert_eq!( + i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]), + 6 + ); + // Encrypted username + assert_eq!(payload[4], b'S' ^ 0xBB); + // Password starts at offset 10 + assert_eq!( + i32::from_le_bytes([payload[10], payload[11], payload[12], payload[13]]), + 6 + ); + // Separator at offset 20 + assert_eq!(&payload[20..24], &[0u8; 4]); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/ready.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/ready.rs new file mode 100644 index 000000000..cf6bb32af --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/ready.rs @@ -0,0 +1,145 @@ +//! READY message (type 3) and ACK response (type 187). + +use bytes::{BufMut, BytesMut}; + +use crate::error::Result; + +/// Client->Server READY message (type 3). +/// +/// Sent to confirm connection is ready or as a keepalive. +#[derive(Debug, Clone)] +pub struct ReadyMessage { + pub flags: u8, +} + +impl ReadyMessage { + /// Create a new ready message. + pub fn new() -> Self { + Self { flags: 1 } + } + + /// Encode to payload bytes. + pub fn encode_payload(&self) -> BytesMut { + let mut buf = BytesMut::new(); + buf.put_u8(self.flags); + buf.put_u8(0); + buf.put_u8(0); + buf.put_u8(0); + buf + } +} + +/// Server->Client ACK response (type 187). +/// +/// Generic success response for most operations. +#[derive(Debug, Clone)] +pub struct AckResponse { + pub status: u8, + pub rows_affected: i64, + pub statement_id: u32, + pub message: String, +} + +impl AckResponse { + /// Parse from raw payload bytes. + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() < 4 { + return Err(crate::error::Error::Incomplete); + } + + let status = data[0]; + let _reserved = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); + let rows_affected = if data.len() >= 16 { + i64::from_le_bytes([ + data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15], + ]) + } else { + 0 + }; + + let statement_id = if data.len() >= 36 { + u32::from_le_bytes([data[32], data[33], data[34], data[35]]) + } else { + 0 + }; + + // Message string at offset 52 + let message = if data.len() >= 56 { + let msg_len = u32::from_le_bytes([data[52], data[53], data[54], data[55]]) as usize; + let msg_end = (56 + msg_len).min(data.len()); + String::from_utf8_lossy(&data[56..msg_end]).to_string() + } else { + String::new() + }; + + Ok(Self { + status, + rows_affected, + statement_id, + message, + }) + } + + /// Check if this is a success response. + pub fn is_success(&self) -> bool { + self.status == 1 || self.status == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ready_new() { + let ready = ReadyMessage::new(); + assert_eq!(ready.flags, 1); + } + + #[test] + fn test_ready_encode_size() { + let ready = ReadyMessage::new(); + let payload = ready.encode_payload(); + assert_eq!(payload.len(), 4); + } + + #[test] + fn test_ack_from_bytes_success() { + let mut data = [0u8; 64]; + data[0] = 1; // status = success + // Message "Success" at offset 56 + data[52] = 7; // msg_len + let msg = b"Success"; + data[56..56 + msg.len()].copy_from_slice(msg); + + let ack = AckResponse::from_bytes(&data).unwrap(); + assert_eq!(ack.status, 1); + assert_eq!(ack.message, "Success"); + assert!(ack.is_success()); + } + + #[test] + fn test_ack_from_bytes_incomplete() { + let data = [0u8; 2]; + let result = AckResponse::from_bytes(&data); + assert!(matches!(result, Err(crate::error::Error::Incomplete))); + } + + #[test] + fn test_ack_is_success() { + let ack = AckResponse { + status: 0, + rows_affected: 0, + statement_id: 0, + message: String::new(), + }; + assert!(ack.is_success()); + } + + #[test] + fn test_ready_flags() { + let ready = ReadyMessage { flags: 0 }; + let payload = ready.encode_payload(); + assert_eq!(payload[0], 0); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/response.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/response.rs new file mode 100644 index 000000000..ccc6e206f --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/response.rs @@ -0,0 +1,1244 @@ +//! EXEC_RESPONSE (type 0 / 187) - Statement execution results. +//! +//! Format verified against DM 8.1.3.62 live traffic. +//! Used for both EXEC (type 5) and OPTIMIZED_PREPARE_EXEC (type 91) responses. +//! +//! === FIXED HEADER (16 bytes) === +//! 0 u32 sub_type (2 for V$VERSION, 7 for SELECT, etc.) +//! 4 u32 flags (usually 4) +//! 8 u32 reserved (0) +//! 12 u32 row_count_in_response +//! +//! === FIRST COLUMN HEADER (16 bytes, offset 16) === +//! 16 u32 col_type (type code for first column) +//! 20 u16 nullable +//! 22 u16 col_count (total number of columns) +//! 24 u16 col_name_len (length of first column name) +//! 26 u16 type_name_len +//! 28 u16 table_name_len +//! 30 u16 schema_name_len +//! +//! === COLUMN VARIABLE DATA === +//! First column strings (explicit lengths from header fields): +//! col_name (col_name_len bytes) +//! type_name (type_name_len bytes) +//! table_name (table_name_len bytes, if > 0) +//! schema_name (schema_name_len bytes, if > 0) +//! null_terminator (1 byte, 0x00) +//! +//! For each subsequent column N (N > 1): +//! Between-columns metadata (12 bytes): nullable_flags(u32) + precision(u32) + reserved(u32) +//! Column N header (19 bytes): +//! col_type(u32) + nullable(u16) + display(u16) + reserved(u8) + col_index(u8) +//! + col_name_len(u16) + type_name_len(u16) + table_name_len(u16) + schema_name_len(u16) + padding(u8) +//! Column N strings: +//! padding(u8) + col_name + type_name + table_name + schema_name + terminator(u8) +//! +//! === OPE INLINE ROW DATA (for OPTIMIZED_PREPARE_EXEC type 91) === +//! After all column metadata, rows are embedded inline: +//! u8 row_size_marker (total bytes for this row including marker) +//! u8 flags +//! u32 rec_id +//! u32 padding (0) +//! For each column: u16 col_offset_from_marker +//! For each column: u16 value_size + value_size bytes of data + +use crate::error::Result; +use dameng_types::encoding::{decode_from_server, ServerEncoding}; + +/// LOB_LOCATOR size: DM returns a 16-byte locator for large CLOB/BLOB values. +/// When the value size exceeds 2048 bytes, DM returns a locator instead of inline data. +/// The client must use LOBREAD/FETCH operations to retrieve the actual content. +pub const LOB_LOCATOR_SIZE: usize = 16; + +/// Maximum inline data size before DM uses LOB_LOCATOR. +pub const LOB_LOCATOR_THRESHOLD: usize = 2048; + +/// Check if the given raw data is a LOB_LOCATOR (16 bytes) for the specified column type. +/// DM uses LOB_LOCATOR for CLOB/BLOB values larger than 2048 bytes. +pub fn is_lob_locator(data: &[u8], col_type_code: i32) -> bool { + let is_lob_type = matches!(col_type_code, 13 | 14); // BLOB=13, CLOB=14 + is_lob_type && data.len() == LOB_LOCATOR_SIZE +} + +/// Check if the given raw data is an NBLOB_HEAD structure for the specified column type. +/// DM returns NBLOB_HEAD for ALL CLOB/BLOB values (both inline and out-of-row). +/// - in_row=0x01: inline data follows the header (13 bytes header + data) +/// - in_row=0x02: out-of-row LOB (25+ bytes header, needs LOBREAD protocol) +pub fn is_lob_head(data: &[u8], col_type_code: i32) -> bool { + let is_lob_type = matches!(col_type_code, 13 | 14); + is_lob_type && data.len() >= 13 +} + +use dameng_types::{DmValue, DmValueType}; + +/// Derive type_code from type_name string. +/// The column header type_code field (offset 16) is unreliable on DM 8.1 — it +/// often returns 4 (INT) for all types. type_name is the authoritative source. +fn type_name_to_code(name: &str) -> i32 { + let upper = name.to_uppercase(); + // Check timezone variants first (longer match before shorter) + if upper.contains("TIMESTAMP WITH TIME ZONE") + || upper.contains("DATETIME WITH TIME ZONE") + || upper.contains("DATETIME2_TZ") + { + 12 // TIMESTAMP_TZ maps to TIMESTAMP (12) + } else if upper.contains("TIME WITH TIME ZONE") { + 11 // TIME_TZ maps to TIME (11) + } else if upper.contains("INTERVAL DAY") + || upper.contains("INTERVAL_DS") + || upper.contains("NUMTODSINTERVAL") + { + 15 // INTERVAL_DT + } else if upper.contains("INTERVAL YEAR") + || upper.contains("INTERVAL_YM") + || upper.contains("NUMTOYMINTERVAL") + { + 15 // INTERVAL_YM + } else { + match upper.as_str() { + "BIT" | "BOOLEAN" => 1, + "TINYINT" => 2, + "VARCHAR" | "CHAR" | "BANNECHAR" | "VARCHAR2" | "NVARCHAR" | "NVARCHAR2" => 3, + "INT" | "INTEGER" | "NUMBER" => 4, + "BIGINT" | "LONG" => 5, + "SMALLINT" => 6, + "FLOAT" => 7, + "DOUBLE" | "DOUBLE PRECISION" => 8, + "DEC" | "DECIMAL" | "NUMERIC" => 9, + "DATE" => 10, + "TIME" => 11, + "TIMESTAMP" | "DATETIME" | "DATETIME2" => 12, + "BLOB" | "RAW" | "LONG RAW" => 13, + "CLOB" | "NCLOB" | "TEXT" => 14, + "INTERVAL" => 15, + "BINARY" | "VARBINARY" => 17, + "ROWID" => 18, + "XMLTYPE" => 14, // XML stored as CLOB-like + _ => 0, + } + } +} + +/// Column metadata from a query result. +#[derive(Debug, Clone)] +pub struct Column { + /// Column name. + pub name: String, + /// DM type code. + pub type_code: i32, + /// Type name string (e.g., "INT", "VARCHAR"). + pub type_name: String, + /// Precision for numeric types. + pub precision: u32, + /// Scale for decimal types. + pub scale: i16, + /// Whether the column can be NULL. + pub nullable: bool, + /// Display size. + pub display_size: u32, + /// Table name. + pub table_name: String, + /// Schema name. + pub schema_name: String, + /// LOB tab_id (only set for BLOB/CLOB columns). + pub lob_tab_id: i32, + /// LOB col_id (only set for BLOB/CLOB columns). + pub lob_col_id: i16, +} + +/// A single row of data from a query result. +#[derive(Debug, Clone)] +pub struct Row { + /// Row ID from the database. + pub row_id: u16, + /// Column values as raw bytes. + pub values: Vec>>, +} + +impl Row { + /// Get an i32 value at the given column index. + pub fn get_i32(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + if val.len() < 4 { + if val.len() == 1 { + return Ok(val[0] as i32); + } + if val.len() == 2 { + return Ok(i32::from(i16::from_le_bytes([val[0], val[1]]))); + } + return Err(crate::error::Error::DecodeError(format!( + "column {} too short for i32 ({} bytes)", + idx, + val.len() + ))); + } + Ok(i32::from_le_bytes([val[0], val[1], val[2], val[3]])) + } + + /// Get an i64 value at the given column index. + pub fn get_i64(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + if val.len() < 8 { + if val.len() >= 4 { + return Ok(i64::from(i32::from_le_bytes([ + val[0], val[1], val[2], val[3], + ]))); + } + return Err(crate::error::Error::DecodeError(format!( + "column {} too short for i64", + idx + ))); + } + Ok(i64::from_le_bytes([ + val[0], val[1], val[2], val[3], val[4], val[5], val[6], val[7], + ])) + } + + /// Get a &str value at the given column index. + /// + /// For text types (VARCHAR, CHAR, CLOB) this reads UTF-8 directly. + pub fn get_str(&self, idx: usize) -> Result<&str> { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + std::str::from_utf8(val) + .map_err(|e| crate::error::Error::DecodeError(format!("invalid UTF-8: {}", e))) + } + + /// Get a f64 value at the given column index. + pub fn get_f64(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + if val.len() < 8 { + return Err(crate::error::Error::DecodeError(format!( + "column {} too short for f64", + idx + ))); + } + let bytes: [u8; 8] = val[..8].try_into().unwrap(); + Ok(f64::from_le_bytes(bytes)) + } + + /// Check if the value at the given column index is NULL. + pub fn is_null(&self, idx: usize) -> bool { + match self.values.get(idx) { + None | Some(None) => true, + Some(Some(v)) => v.is_empty(), + } + } + + /// Get a TIMESTAMP value at the given column index as a human-readable string. + /// + /// DM encodes TIMESTAMP as 11 bytes: year(2 BE) + month(1) + day(1) + hour(1) + minute(1) + second(1) + nanosecond(4 BE). + /// Falls back to UTF-8/lossy if the data doesn't match binary format. + pub fn get_timestamp(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + + if val.len() == 11 { + let year = u16::from_be_bytes([val[0], val[1]]) as i32; + let month = val[2]; + let day = val[3]; + let hour = val[4]; + let minute = val[5]; + let second = val[6]; + let nano = u32::from_be_bytes([val[7], val[8], val[9], val[10]]); + if nano > 0 { + Ok(format!( + "{}-{:02}-{:02} {:02}:{:02}:{:02}.{:09}", + year, month, day, hour, minute, second, nano + )) + } else { + Ok(format!( + "{}-{:02}-{:02} {:02}:{:02}:{:02}", + year, month, day, hour, minute, second + )) + } + } else if val.len() == 7 { + // DATE format: year(2 BE) + month(1) + day(1) + hour(1) + minute(1) + second(1) + let year = u16::from_be_bytes([val[0], val[1]]) as i32; + let month = val[2]; + let day = val[3]; + let hour = val[4]; + let minute = val[5]; + let second = val[6]; + Ok(format!( + "{}-{:02}-{:02} {:02}:{:02}:{:02}", + year, month, day, hour, minute, second + )) + } else { + // Fallback: UTF-8 or lossy via get_string + self.get_string(idx) + } + } + + /// Get a DATE value at the given column index as a human-readable string. + pub fn get_date(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + + if val.len() == 7 { + let year = u16::from_be_bytes([val[0], val[1]]) as i32; + let month = val[2]; + let day = val[3]; + let hour = val[4]; + let minute = val[5]; + let second = val[6]; + Ok(format!( + "{}-{:02}-{:02} {:02}:{:02}:{:02}", + year, month, day, hour, minute, second + )) + } else { + self.get_string(idx) + } + } + + /// Get the number of columns in this row. + pub fn len(&self) -> usize { + self.values.len() + } + + /// Check if the row has no columns. + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// Get a decoded DmValue at the given column index. + /// Uses the column type_code to decode the raw bytes. + pub fn get(&self, idx: usize, columns: &[Column]) -> Option { + let data = self.values.get(idx)?.as_ref()?; + if data.is_empty() { + return Some(DmValue::Null); + } + let col = columns.get(idx)?; + let dm_ty = DmValueType::from_type_code(col.type_code)?; + dameng_types::decode_value( + dm_ty, + data, + matches!(dm_ty, DmValueType::BLOB | DmValueType::CLOB) + .then_some((col.lob_tab_id, col.lob_col_id)), + ) + } + + /// Get an i16 value at the given column index. + pub fn get_i16(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + if val.len() < 2 { + if val.len() == 1 { + return Ok(val[0] as i16); + } + return Err(crate::error::Error::DecodeError(format!( + "column {} too short for i16", + idx + ))); + } + Ok(i16::from_le_bytes([val[0], val[1]])) + } + + /// Get an i8 value at the given column index. + pub fn get_i8(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + if val.is_empty() { + return Err(crate::error::Error::DecodeError(format!( + "column {} is NULL", + idx + ))); + } + Ok(val[0] as i8) + } + + /// Get a f32 value at the given column index. + pub fn get_f32(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + if val.len() < 4 { + return Err(crate::error::Error::DecodeError(format!( + "column {} too short for f32", + idx + ))); + } + Ok(f32::from_le_bytes([val[0], val[1], val[2], val[3]])) + } + + /// Get raw bytes at the given column index. + pub fn get_bytes(&self, idx: usize) -> Result> { + match self.values.get(idx) { + Some(Some(v)) => Ok(v.clone()), + Some(None) => Ok(vec![]), + None => Err(crate::error::Error::DecodeError(format!( + "column {} out of range", + idx + ))), + } + } + + /// Get an Option at the given column index (NULL-safe). + pub fn get_opt_i32(&self, idx: usize) -> Result> { + match self.values.get(idx) { + Some(Some(v)) if !v.is_empty() => Ok(Some(self.get_i32(idx)?)), + _ => Ok(None), + } + } + + /// Get an Option at the given column index (NULL-safe). + pub fn get_opt_i64(&self, idx: usize) -> Result> { + match self.values.get(idx) { + Some(Some(v)) if !v.is_empty() => Ok(Some(self.get_i64(idx)?)), + _ => Ok(None), + } + } + + /// Get an Option<&str> at the given column index (NULL-safe). + pub fn get_opt_str(&self, idx: usize) -> Result> { + match self.values.get(idx) { + Some(Some(v)) if !v.is_empty() => { + Ok(Some(std::str::from_utf8(v).map_err(|e| { + crate::error::Error::DecodeError(format!("invalid UTF-8: {}", e)) + })?)) + } + _ => Ok(None), + } + } + + /// Get an Option at the given column index (NULL-safe). + pub fn get_opt_f64(&self, idx: usize) -> Result> { + match self.values.get(idx) { + Some(Some(v)) if !v.is_empty() => Ok(Some(self.get_f64(idx)?)), + _ => Ok(None), + } + } + + /// Get an owned String at the given column index (uses lossy UTF-8 fallback). + /// + /// Unlike `get_str()` which returns a borrowed `&str` and fails on invalid UTF-8, + /// this method always succeeds by replacing invalid sequences with U+FFFD. + /// Useful for binary-ish data or server status strings. + pub fn get_string(&self, idx: usize) -> Result { + let val = self.values.get(idx).and_then(|v| v.as_ref()).ok_or( + crate::error::Error::DecodeError(format!("column {} is NULL or out of range", idx)), + )?; + Ok(String::from_utf8_lossy(val).into_owned()) + } + + /// Placeholder: find column index by name (case-insensitive match). + pub fn column_index(&self, _columns: &[Column]) -> usize { + 0 + } +} + +/// Server->Client EXEC_RESPONSE (type 0). +#[derive(Debug, Clone)] +pub struct ExecResponse { + /// Number of columns in the result. + pub col_count: u16, + /// Number of rows returned. + pub row_count: u32, + /// Column metadata. + pub columns: Vec, + /// Row data (column-major order). + pub rows: Vec, +} + +/// Decode DM binary DECIMAL to text representation. +fn decode_dm_decimal_to_text(data: &[u8], _scale: i16) -> Option { + const FLAG_ZERO: u8 = 0x80; + const FLAG_POSITIVE: i32 = 0xC1; + const FLAG_NEGTIVE: i32 = 0x3E; + const NUM_POSITIVE: i32 = 1; + const NUM_NEGTIVE: i32 = 101; + if data.is_empty() || data.len() > 21 { + return None; + } + if data[0] == FLAG_ZERO || data.len() == 1 { + return Some("0".to_string()); + } + let is_positive = data[0] & FLAG_ZERO != 0; + let flag = data[0] as i32; + let exponent = if is_positive { + flag - FLAG_POSITIVE + } else { + FLAG_NEGTIVE - flag + }; + let mut digits = Vec::with_capacity(data.len() - 1); + for &b in &data[1..] { + let digit = if is_positive { + b as i32 - NUM_POSITIVE + } else { + NUM_NEGTIVE - b as i32 + }; + if digit < 0 || digit > 99 { + break; + } + digits.push(digit); + } + if digits.is_empty() { + return None; + } + + let decimal_group = exponent + 1; + let mut value = String::new(); + if !is_positive { + value.push('-'); + } + if decimal_group <= 0 { + value.push_str("0."); + for _ in 0..-decimal_group { + value.push_str("00"); + } + for digit in digits { + value.push_str(&format!("{digit:02}")); + } + } else { + for index in 0..decimal_group as usize { + if index < digits.len() { + if index == 0 { + value.push_str(&digits[index].to_string()); + } else { + value.push_str(&format!("{:02}", digits[index])); + } + } else { + value.push_str("00"); + } + } + if (decimal_group as usize) < digits.len() { + value.push('.'); + for digit in &digits[decimal_group as usize..] { + value.push_str(&format!("{digit:02}")); + } + } + } + if value.contains('.') { + while value.ends_with('0') { + value.pop(); + } + if value.ends_with('.') { + value.pop(); + } + } + Some(value) +} + +impl ExecResponse { + /// Parse from raw payload bytes. + /// + /// Supports both EXEC (type 5) metadata-only responses and + /// OPTIMIZED_PREPARE_EXEC (type 91) responses with inline row data. + pub fn from_bytes(data: &[u8], server_encoding: ServerEncoding) -> Result { + if data.len() < 16 { + return Err(crate::error::Error::Incomplete); + } + + // === Fixed Header (16 bytes) === + let sub_type = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + let _flags = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); + let _reserved = u32::from_le_bytes([data[8], data[9], data[10], data[11]]); + let header_row_count = u32::from_le_bytes([data[12], data[13], data[14], data[15]]); + + // If data is too short for first column header, return what we can. + // For DML (INSERT/UPDATE/DELETE) the affected row count is in the fixed + // 16-byte header — so we still extract it even without column metadata. + if data.len() < 32 { + return Ok(ExecResponse { + col_count: 0, + row_count: header_row_count, + columns: vec![], + rows: vec![], + }); + } + + // === First Column Header (16 bytes, offset 16) === + // Note: first_col_type at offset 16 is unreliable on DM 8.1 — always returns 4 (INT). + // We derive the correct type_code from the type_name string instead. + let _first_col_type = i32::from_le_bytes([data[16], data[17], data[18], data[19]]); + let first_nullable = u16::from_le_bytes([data[20], data[21]]); + let col_count = u16::from_le_bytes([data[22], data[23]]); + let col_name_len = u16::from_le_bytes([data[24], data[25]]) as usize; + let type_name_len = u16::from_le_bytes([data[26], data[27]]) as usize; + let table_name_len = u16::from_le_bytes([data[28], data[29]]) as usize; + let schema_name_len = u16::from_le_bytes([data[30], data[31]]) as usize; + + let mut columns = Vec::with_capacity(col_count.max(1) as usize); + let mut offset = 32; // Column variable data starts at 32 + + // When col_count > 0, parse first column from the compact 16-byte header. + // When col_count == 0 (BIND_EXEC2 path), ALL columns use the expanded 32-byte + // format starting at offset 16 — skip the compact header parsing entirely. + if col_count > 0 { + // col_name (explicit length from col_name_len field at offset 24-25) + let col_name = if col_name_len > 0 && offset + col_name_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + col_name_len]) + } else { + String::new() + }; + offset += col_name_len; + + // type_name + let type_name = if type_name_len > 0 && offset + type_name_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + type_name_len]) + } else { + String::new() + }; + offset += type_name_len; + + // table_name + let table_name = if table_name_len > 0 && offset + table_name_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + table_name_len]) + } else { + String::new() + }; + offset += table_name_len; + + // schema_name + let schema_name = if schema_name_len > 0 && offset + schema_name_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + schema_name_len]) + } else { + String::new() + }; + offset += schema_name_len; + + // Skip null terminator after first col strings (if present) + if offset < data.len() && data[offset] == 0 { + offset += 1; + } + + // Always derive type_code from type_name — the header field (offset 16) + // is unreliable on DM 8.1 (often returns 4/INT for all types). + let actual_type_code = type_name_to_code(&type_name); + + columns.push(Column { + name: col_name, + type_code: actual_type_code, + type_name, + precision: 0, + scale: 0, + nullable: first_nullable != 0, + display_size: 0, + table_name, + schema_name, + lob_tab_id: 0, + lob_col_id: 0, + }); + } + + // === Subsequent Columns === + // OPE(91) may report col_count=1 even for multi-column queries regardless + // of the response sub-type, so parse columns dynamically until row data. + // + // When col_count == 0 (BIND_EXEC2 path for SELECT with params), the server + // sends NO inline column metadata or row data — the data must be fetched + // via FETCH protocol. In this case skip dynamic parsing entirely. + // + // Verified against DM 8.1.3.62 wire protocol: + // First column: 16-byte compact header (already parsed above) + // Subsequent columns: 32-byte expanded header (NO gap between columns): + // 0 u32 col_type (LE) + // 4 u32 precision (LE) + // 8 u32 scale (LE) + // 12 u32 nullable_flags (LE) + // 16 u32 reserved (LE) + // 20 u16 reserved + // 22 u16 col_index (?) + // 24 u16 name_len + // 26 u16 type_name_len + // 28 u16 table_name_len + // 30 u16 schema_name_len + // 32 [col_name][type_name][table_name][schema_name] (no null terminator) + let use_dynamic = col_count > 0; + // When col_count == 0 (BIND_EXEC2 SELECT path), all columns use the + // expanded 32-byte format starting at offset 16 (no compact first-column header). + // We reuse the dynamic parser logic with col_count as the parsed count. + let max_cols = if use_dynamic { + 4_096 + } else { + (col_count as usize).max(columns.len()) + }; + let mut _parsed_cols = columns.len() as usize; + // For BIND_EXEC2 (col_count == 0), start parsing at offset 16 (right after header), + // reusing the expanded column parser loop below. + if col_count == 0 { + _parsed_cols = 0; + offset = 16; + } + let mut parsed_cols = 1; + while parsed_cols < max_cols { + // Save position before attempting to parse next column. + // If we don't find a valid column header, row data starts here. + let row_start = offset; + + // Subsequent column header is 32 bytes + if offset + 32 > data.len() { + offset = row_start; + break; + } + + let header_off = offset; + + // Compact row format marker — row data starts here + if data[header_off] == 0x0C { + offset = row_start; + break; + } + + // Expanded 32-byte header for subsequent columns + let _c_type = i32::from_le_bytes([ + data[header_off], + data[header_off + 1], + data[header_off + 2], + data[header_off + 3], + ]); + // If c_type is 0 or invalid, we've hit row data — stop parsing columns + if !(1..=31).contains(&_c_type) { + offset = row_start; + break; + } + let c_precision = u32::from_le_bytes([ + data[header_off + 4], + data[header_off + 5], + data[header_off + 6], + data[header_off + 7], + ]); + let c_scale = i32::from_le_bytes([ + data[header_off + 8], + data[header_off + 9], + data[header_off + 10], + data[header_off + 11], + ]); + if c_precision > 1_000_000 || !(-1_000..=1_000).contains(&c_scale) { + offset = row_start; + break; + } + let c_nullable = u32::from_le_bytes([ + data[header_off + 12], + data[header_off + 13], + data[header_off + 14], + data[header_off + 15], + ]); + // reserved at offsets 16-23 (4 bytes + 2 u16) + + // Length fields at offsets 24-31 + let c_name_len = + u16::from_le_bytes([data[header_off + 24], data[header_off + 25]]) as usize; + let c_type_name_len = + u16::from_le_bytes([data[header_off + 26], data[header_off + 27]]) as usize; + let c_table_len = + u16::from_le_bytes([data[header_off + 28], data[header_off + 29]]) as usize; + let c_schema_len = + u16::from_le_bytes([data[header_off + 30], data[header_off + 31]]) as usize; + + // Validate lengths — if unreasonable, we've hit row data + if c_name_len == 0 + || c_type_name_len == 0 + || c_name_len > 128 + || c_type_name_len > 128 + || c_table_len > 128 + || c_schema_len > 128 + { + offset = row_start; + break; + } + + // Strings start at header_off + 32 + offset = header_off + 32; + let c_name = if c_name_len > 0 && offset + c_name_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + c_name_len]) + } else { + offset = row_start; + break; + }; + offset += c_name_len; + + let c_type_name = if c_type_name_len > 0 && offset + c_type_name_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + c_type_name_len]) + } else { + String::new() + }; + offset += c_type_name_len; + + let c_table = if c_table_len > 0 && offset + c_table_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + c_table_len]) + } else { + String::new() + }; + offset += c_table_len; + + let c_schema = if c_schema_len > 0 && offset + c_schema_len <= data.len() { + decode_from_server(server_encoding, &data[offset..offset + c_schema_len]) + } else { + String::new() + }; + offset += c_schema_len; + + // For sub_type=7, the 32-byte header c_type field is unreliable for + // subsequent columns (e.g., VARCHAR returns 2 instead of 3). + // Always derive from type_name string instead. + let actual_c_type = type_name_to_code(&c_type_name); + + // Read itemFlag (offset 16-17 within the 32-byte header) to detect LOB columns. + // itemFlag bits: 0x01=identity, 0x02=lob, 0x04=readonly + let item_flag = u16::from_le_bytes([data[header_off + 16], data[header_off + 17]]); + let is_lob = (item_flag & 0x02) != 0; + + // For LOB columns, DM appends lobTabId (i32 LE) + lobColId (i16 LE) after the strings. + let (c_lob_tab_id, c_lob_col_id) = if is_lob && offset + 6 <= data.len() { + let tab_id = i32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + offset += 4; + let col_id = i16::from_le_bytes([data[offset], data[offset + 1]]); + offset += 2; + (tab_id, col_id) + } else { + (0, 0) + }; + + columns.push(Column { + name: c_name, + type_code: actual_c_type, + type_name: c_type_name, + precision: 0, + scale: 0, + nullable: c_nullable != 0, + display_size: 0, + table_name: c_table, + schema_name: c_schema, + lob_tab_id: c_lob_tab_id, + lob_col_id: c_lob_col_id, + }); + parsed_cols += 1; + } + + // === Inline Row Data (OPE responses only) === + // Two row formats depending on sub_type: + // sub_type=2: compact format (V$VERSION style) - marker(1)+flags(1)+val_size(2)+value(N) + // sub_type=7: full format (SELECT style) - row_hdr+col_offsets+values + let mut rows = Vec::new(); + if columns.is_empty() { + return Ok(Self { + col_count, + row_count: header_row_count, + columns, + rows, + }); + } else if sub_type == 2 && columns.len() == 1 { + // Compact row format (V$VERSION style): + // Each row: marker(0x0C) + flags(1) + val_size(2) + value(N) + padding + while offset + 4 <= data.len() && data[offset] != 0x0C { + offset += 1; + } + while offset + 4 <= data.len() && data[offset] == 0x0C { + let row_start = offset; + let _flags = data[offset + 1]; + let val_size = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize; + if val_size == 0 || offset + 4 + val_size > data.len() { + break; + } + let value_bytes = data[offset + 4..offset + 4 + val_size].to_vec(); + let next_scan = offset + 4 + val_size; + let mut found = false; + for scan in next_scan..data.len() { + if data[scan] == 0x0C { + offset = scan; + found = true; + break; + } + } + if !found { + offset = data.len(); + } + let mut values = Vec::with_capacity(columns.len()); + values.push(Some(value_bytes)); + for _ in 1..columns.len() { + values.push(None); + } + // Decode string columns from server encoding, DECIMAL to text + for ci in 0..columns.len().min(values.len()) { + if matches!(columns[ci].type_code, 3 | 14 | 16 | 23) { + if let Some(ref val_bytes) = values[ci] { + let decoded = decode_from_server(server_encoding, val_bytes); + values[ci] = Some(decoded.into_bytes()); + } + } else if matches!(columns[ci].type_code, 9 | 20) { + if let Some(ref val_bytes) = values[ci] { + if let Some(text) = + decode_dm_decimal_to_text(val_bytes, columns[ci].scale) + { + values[ci] = Some(text.into_bytes()); + } + } + } + } + rows.push(Row { + row_id: row_start as u16, + values, + }); + } + } else { + // Full row format (sub_type=7 and others) + // CRITICAL: The first byte (row_size) does NOT represent actual row length. + // DM 8.1.3.62: row_size=0x23=35 but actual row data spans ~50 bytes. + // Instead, calculate true row end from the column value offsets + sizes. + while offset + 10 <= data.len() { + let row_start = offset; + let _row_size = data[offset]; // Present but unreliable for advancement + let _flags = data[offset + 1]; + let rec_id = u32::from_le_bytes([ + data[offset + 2], + data[offset + 3], + data[offset + 4], + data[offset + 5], + ]); + + // Column offset table: col_count x 2 bytes, starting at row_start + 10 + let offsets_start = row_start + 10; + let col_offsets: Vec = (0..columns.len()) + .map(|c| { + let o = offsets_start + c * 2; + if o + 2 <= data.len() { + u16::from_le_bytes([data[o], data[o + 1]]) + } else { + 0 + } + }) + .collect(); + + // Parse values and track the furthest byte consumed + let mut values = Vec::with_capacity(columns.len()); + let mut row_end = offsets_start + columns.len() * 2; + for (_ci, col_off) in col_offsets.iter().enumerate() { + let val_abs = row_start + *col_off as usize; + if val_abs + 2 > data.len() { + values.push(None); + continue; + } + let val_size = u16::from_le_bytes([data[val_abs], data[val_abs + 1]]) as usize; + if val_size == 0 { + values.push(None); + } else if val_abs + 2 + val_size <= data.len() { + values.push(Some(data[val_abs + 2..val_abs + 2 + val_size].to_vec())); + let val_end = val_abs + 2 + val_size; + if val_end > row_end { + row_end = val_end; + } + } else { + values.push(None); + } + } + + offset = row_end; + let reported = _row_size as usize; + if reported > 0 && row_start + reported > offset { + offset = row_start + reported; + } + // Decode string columns from server encoding, DECIMAL to text + for ci in 0..columns.len().min(values.len()) { + if matches!(columns[ci].type_code, 3 | 14 | 16 | 23) { + if let Some(ref val_bytes) = values[ci] { + let decoded = decode_from_server(server_encoding, val_bytes); + values[ci] = Some(decoded.into_bytes()); + } + } else if matches!(columns[ci].type_code, 9 | 20) { + if let Some(ref val_bytes) = values[ci] { + if let Some(text) = + decode_dm_decimal_to_text(val_bytes, columns[ci].scale) + { + values[ci] = Some(text.into_bytes()); + } + } + } + } + rows.push(Row { + row_id: rec_id as u16, + values, + }); + } + } + + Ok(Self { + col_count, + row_count: header_row_count, + columns, + rows, + }) + } + + /// Helper to safely read u32 LE. + #[allow(dead_code)] + fn safe_u32(data: &[u8], offset: usize) -> u32 { + if offset + 4 <= data.len() { + u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) + } else { + 0 + } + } + + /// Check if this response contains result rows. + pub fn has_rows(&self) -> bool { + !self.rows.is_empty() + } + + /// Get the number of columns. + pub fn num_columns(&self) -> usize { + self.columns.len() + } + + /// Get the number of rows. + pub fn num_rows(&self) -> usize { + self.rows.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_row_get_i32() { + let row = Row { + row_id: 0, + values: vec![Some(vec![1, 0, 0, 0])], + }; + assert_eq!(row.get_i32(0).unwrap(), 1); + } + + #[test] + fn test_row_get_i32_single_byte() { + let row = Row { + row_id: 0, + values: vec![Some(vec![42])], + }; + assert_eq!(row.get_i32(0).unwrap(), 42); + } + + #[test] + fn test_row_get_str() { + let row = Row { + row_id: 0, + values: vec![Some(b"hello".to_vec())], + }; + assert_eq!(row.get_str(0).unwrap(), "hello"); + } + + #[test] + fn test_row_is_null() { + let row = Row { + row_id: 0, + values: vec![None, Some(vec![1, 2, 3])], + }; + assert!(row.is_null(0)); + assert!(!row.is_null(1)); + } + + #[test] + fn test_row_len() { + let row = Row { + row_id: 0, + values: vec![Some(vec![1]), Some(vec![2]), Some(vec![3])], + }; + assert_eq!(row.len(), 3); + assert!(!row.is_empty()); + } + + #[test] + fn test_row_get_i64() { + let row = Row { + row_id: 0, + values: vec![Some(vec![42, 0, 0, 0, 0, 0, 0, 0])], + }; + assert_eq!(row.get_i64(0).unwrap(), 42); + } + + #[test] + fn test_exec_response_has_rows() { + let resp = ExecResponse { + col_count: 0, + row_count: 0, + columns: vec![], + rows: vec![], + }; + assert!(!resp.has_rows()); + } + + #[test] + fn test_exec_response_minimal_empty() { + // Valid empty response: header(16) + col_header(16) + null_terminator(1) + let data = [ + 0x07, 0x00, 0x00, 0x00, // sub_type + 0x04, 0x00, 0x00, 0x00, // flags + 0x00, 0x00, 0x00, 0x00, // reserved + 0x00, 0x00, 0x00, 0x00, // row_count = 0 + 0x00, 0x00, 0x00, 0x00, // col_type = 0 + 0x00, 0x00, // nullable + 0x00, 0x00, // display + 0x00, 0x00, // col_count = 0 + 0x00, 0x00, // type_name_len + 0x00, 0x00, // table_name_len + 0x00, 0x00, // schema_name_len + ]; + let resp = ExecResponse::from_bytes(&data, ServerEncoding::Utf8).unwrap(); + assert_eq!(resp.col_count, 0); + assert_eq!(resp.num_columns(), 0); + assert_eq!(resp.num_rows(), 0); + } + + #[test] + fn test_exec_response_select1_ope() { + // OPE response for "SELECT 1 FROM DUAL" (58 bytes) + let data: Vec = vec![ + 0x07, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, // header (row_count=1) + 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00, 0x00, + 0x00, + 0x00, // col1 header (type=4, nullable=0, col_count=1, col_name_len=1, type_name_len=7) + 0x31, 0x49, 0x4e, 0x54, 0x45, 0x47, 0x45, 0x52, + 0x00, // col1 strings: "1" + "INTEGER" + \0 + // Row data (18 bytes): marker=18, flags=0, rec_id=0, padding=0, col_off=12, val_size=4, val=1 + 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x04, 0x00, + 0x01, 0x00, 0x00, 0x00, + ]; + let resp = ExecResponse::from_bytes(&data, ServerEncoding::Utf8).unwrap(); + assert_eq!(resp.col_count, 1); + assert_eq!(resp.num_columns(), 1); + assert_eq!(resp.num_rows(), 1); + assert_eq!(resp.columns[0].name, "1"); + assert_eq!(resp.columns[0].type_name, "INTEGER"); + assert_eq!(resp.columns[0].type_code, 4); + assert_eq!(resp.rows[0].get_i32(0).unwrap(), 1); + } + + #[test] + fn test_exec_response_select1_ope_no_null_term() { + // Actual OPE response from DM 8.1.3.62 - no \0 terminator (58 bytes) + let data: Vec = vec![ + 0x07, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, // header (row_count=1) + 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00, 0x00, + 0x00, 0x00, // col1 header (type=4, nullable=0, col_count=1, col_name_len=1) + // Strings: "1" + "INTEGER" (no \0 terminator!) + 0x31, 0x49, 0x4e, 0x54, 0x45, 0x47, 0x45, 0x52, + // Row data: marker=18, flags=0, rec_id=0, padding=0, col_off=12, val_size=4, val=1 + 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x04, 0x00, + 0x01, 0x00, 0x00, 0x00, + ]; + let resp = ExecResponse::from_bytes(&data, ServerEncoding::Utf8).unwrap(); + assert_eq!(resp.col_count, 1); + assert_eq!(resp.num_columns(), 1); + assert_eq!(resp.num_rows(), 1); + assert_eq!(resp.columns[0].name, "1"); + assert_eq!(resp.columns[0].type_name, "INTEGER"); + assert_eq!(resp.columns[0].type_code, 4); + assert_eq!(resp.rows[0].get_i32(0).unwrap(), 1); + } + + #[test] + fn test_exec_response_subtype2_with_multiple_columns() { + // Captured from DM8 for SELECT NAME, NAME FROM a one-row table. DM reports + // col_count=1 even though a second expanded column header follows. + let data: Vec = vec![ + 0x02, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0x07, 0x00, + 0x11, 0x00, 0x06, 0x00, 0x4e, 0x41, 0x4d, 0x45, 0x56, 0x41, 0x52, 0x43, 0x48, 0x41, + 0x52, 0x54, 0x41, 0x42, 0x4c, 0x45, 0x50, 0x52, 0x4f, 0x5f, 0x4c, 0x4f, 0x42, 0x5f, + 0x54, 0x45, 0x53, 0x54, 0x53, 0x59, 0x53, 0x44, 0x42, 0x41, 0x02, 0x00, 0x00, 0x00, + 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0x07, 0x00, 0x11, 0x00, 0x06, 0x00, + 0x4e, 0x41, 0x4d, 0x45, 0x56, 0x41, 0x52, 0x43, 0x48, 0x41, 0x52, 0x54, 0x41, 0x42, + 0x4c, 0x45, 0x50, 0x52, 0x4f, 0x5f, 0x4c, 0x4f, 0x42, 0x5f, 0x54, 0x45, 0x53, 0x54, + 0x53, 0x59, 0x53, 0x44, 0x42, 0x41, 0x1c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0e, 0x00, 0x15, 0x00, 0x05, 0x00, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x05, + 0x00, 0x68, 0x65, 0x6c, 0x6c, 0x6f, + ]; + + let resp = ExecResponse::from_bytes(&data, ServerEncoding::Utf8).unwrap(); + + assert_eq!(resp.col_count, 1); + assert_eq!(resp.num_columns(), 2); + assert_eq!(resp.num_rows(), 1); + assert_eq!(resp.rows[0].get_str(0).unwrap(), "hello"); + assert_eq!(resp.rows[0].get_str(1).unwrap(), "hello"); + } + + #[test] + fn test_decode_dm_decimal_to_text_honors_exponent() { + assert_eq!( + decode_dm_decimal_to_text(&[0xc2, 0x02], 0).as_deref(), + Some("100") + ); + assert_eq!( + decode_dm_decimal_to_text(&[0xc2, 0x52, 0x59], 0).as_deref(), + Some("8188") + ); + assert_eq!( + decode_dm_decimal_to_text(&[0xc1, 0x02, 0x18], 0).as_deref(), + Some("1.23") + ); + assert_eq!( + decode_dm_decimal_to_text(&[0x3e, 0x64, 0x4e, 0x66], 0).as_deref(), + Some("-1.23") + ); + } + + #[test] + fn test_exec_response_incomplete() { + let data = [0x00, 0x00, 0x00]; + let result = ExecResponse::from_bytes(&data, ServerEncoding::Utf8); + assert!(matches!(result, Err(crate::error::Error::Incomplete))); + } + + #[test] + fn test_exec_response_short_payload_dml() { + // INSERT/UPDATE/DELETE response with only 16-byte header (no column metadata). + // The affected row count must still be extracted from the header. + let data = [ + 0x07, 0x00, 0x00, 0x00, // sub_type + 0x04, 0x00, 0x00, 0x00, // flags + 0x00, 0x00, 0x00, 0x00, // reserved + 0x01, 0x00, 0x00, 0x00, // row_count = 1 (affected rows) + ]; + let resp = ExecResponse::from_bytes(&data, ServerEncoding::Utf8).unwrap(); + assert_eq!(resp.row_count, 1); + assert_eq!(resp.col_count, 0); + assert_eq!(resp.num_columns(), 0); + assert_eq!(resp.num_rows(), 0); + } + + #[test] + fn test_row_get_i32_null() { + let row = Row { + row_id: 0, + values: vec![None], + }; + assert!(row.get_i32(0).is_err()); + assert!(row.is_null(0)); + } + + #[test] + fn test_row_get_str_empty() { + let row = Row { + row_id: 0, + values: vec![Some(vec![])], + }; + let result = row.get_str(0).unwrap(); + assert_eq!(result, ""); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/startup.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/startup.rs new file mode 100644 index 000000000..253e39f2f --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/startup.rs @@ -0,0 +1,276 @@ +//! STARTUP message (type 200) and STARTUP_RESPONSE (type 228). +//! +//! Reverse-engineered from captured wire protocol traffic of the official +//! Python dmPython driver. See scripts/proxy.py and capture logs. + +use bytes::{BufMut, BytesMut}; + +use crate::error::Result; + +/// Client->Server STARTUP message (type 200). +/// +/// Wire format (captured from working Python driver): +/// ```text +/// Offset Size Field +/// 0 4 Driver version string length (i32 LE) +/// 4 N Driver version string (UTF-8, e.g. "8.1.1.126") +/// N+4 1 Null terminator (0x00) +/// N+5 4 Encryption key length (i32 LE, always 64) +/// N+9 64 Encryption key bytes (client-generated random) +/// ``` +#[derive(Debug, Clone)] +pub struct StartupMessage { + /// Driver version string (e.g. "8.1.1.126" or "7.6.0.0"). + pub driver_version: String, + /// 64-byte encryption key. + pub encryption_key: [u8; 64], +} + +impl StartupMessage { + /// Create a new startup message with default values. + pub fn new() -> Self { + // Generate random-looking key bytes (XOR pattern to avoid all-zeros) + let mut key = [0u8; 64]; + for i in 0..64 { + key[i] = ((i * 7 + 13) & 0xFF) as u8; + } + Self { + driver_version: "7.6.0.0".to_string(), + encryption_key: key, + } + } + + /// Encode to payload bytes. + pub fn encode_payload(&self) -> BytesMut { + let ver_bytes = self.driver_version.as_bytes(); + let key_len = self.encryption_key.len(); + let total = 4 + ver_bytes.len() + 1 + 4 + key_len; + let mut buf = BytesMut::with_capacity(total); + + // i32 LE: version string length + buf.put_i32_le(ver_bytes.len() as i32); + // Version string bytes + buf.put_slice(ver_bytes); + // Null terminator + buf.put_u8(0); + // i32 LE: encryption key length (always 64) + buf.put_i32_le(key_len as i32); + // 64 bytes of encryption key + buf.put_slice(&self.encryption_key); + + buf + } +} + +/// Server->Client STARTUP_RESPONSE message (type 228 for success, type 187 for error). +/// +/// Wire format (captured from working Python driver): +/// ```text +/// Offset Size Field +/// 0 16 Reserved (zeros) +/// 16 4 Server version string length (i32 LE) +/// 20 N Server version string (UTF-8, e.g. "8.1.3.62") +/// 20+N 4 Padding/sentinel (i32 LE, usually -1) +/// 24+N 4 Challenge length (i32 LE, always 64) +/// 28+N 64 Challenge/encryption key bytes +/// 92+N var Additional server data +/// ``` +#[derive(Debug, Clone)] +pub struct StartupResponse { + /// Server encoding (1=UTF-8, 2=GB18030). + pub encoding: u8, + /// Server challenge for encryption (48-64 bytes). + pub challenge: Vec, + /// Server version string. + pub server_version: String, + /// Server encryption public key. + pub encryption_key: Vec, + /// Response code from server frame header. + pub response_code: i32, + /// Session ID from server. + pub session_id: u32, +} + +impl StartupResponse { + /// Parse from payload bytes and response code from the frame header. + pub fn from_bytes(data: &[u8], response_code: i32) -> Result { + // Check for error response (negative response code) + if response_code < 0 { + let mut server_version = String::new(); + if data.len() >= 12 { + let msg_len = u32::from_le_bytes([ + data[8].min(255), + data.get(9).copied().unwrap_or(0), + data.get(10).copied().unwrap_or(0), + data.get(11).copied().unwrap_or(0), + ]) as usize; + if data.len() > 12 + msg_len { + server_version = String::from_utf8_lossy(&data[12..12 + msg_len]).to_string(); + } + } + return Ok(Self { + encoding: 0, + challenge: vec![], + server_version, + encryption_key: vec![], + response_code, + session_id: 0, + }); + } + + // Parse successful startup response + let mut server_version = String::new(); + let mut challenge = Vec::new(); + let encryption_key = Vec::new(); + let mut encoding = 1u8; // default UTF-8 + let session_id = 0u32; + + if data.len() >= 20 { + // Server version string length at offset 16 + let ver_len = u32::from_le_bytes([ + data[16], + data.get(17).copied().unwrap_or(0), + data.get(18).copied().unwrap_or(0), + data.get(19).copied().unwrap_or(0), + ]) as usize; + + let ver_start = 20; + if ver_len > 0 && data.len() > ver_start + ver_len { + server_version = + String::from_utf8_lossy(&data[ver_start..ver_start + ver_len]).to_string(); + } + + // After version: sentinel (-1), then key length (64) + let after_ver = ver_start + ver_len; + if after_ver + 8 <= data.len() { + // Key length at after_ver+4 + let key_len = u32::from_le_bytes([ + data[after_ver + 4], + data.get(after_ver + 5).copied().unwrap_or(0), + data.get(after_ver + 6).copied().unwrap_or(0), + data.get(after_ver + 7).copied().unwrap_or(0), + ]) as usize; + + let key_start = after_ver + 8; + if key_len > 0 && data.len() > key_start + key_len.min(64) { + challenge = data[key_start..key_start + key_len.min(64)].to_vec(); + } + } + } + + // Try to extract encoding from the payload + // From the capture, encoding seems to be embedded in the response + // For now, default to UTF-8 + if data.len() >= 44 { + // Encoding might be at specific offsets in the response + encoding = 1; // UTF-8 + } + + Ok(Self { + encoding, + challenge, + server_version, + encryption_key, + response_code, + session_id, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_startup_encode_default_version() { + let msg = StartupMessage::new(); + let payload = msg.encode_payload(); + // 4 (len) + 7 (ver) + 1 (null) + 4 (key_len) + 64 (key) = 80 + assert_eq!(payload.len(), 80); + + // Verify version length field + assert_eq!( + i32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]), + 7 + ); + // Verify version string + assert_eq!(&payload[4..11], b"7.6.0.0"); + // Verify null terminator + assert_eq!(payload[11], 0); + // Verify key length + assert_eq!( + i32::from_le_bytes([payload[12], payload[13], payload[14], payload[15]]), + 64 + ); + } + + #[test] + fn test_startup_encode_custom_version() { + let msg = StartupMessage { + driver_version: "8.1.1.126".to_string(), + encryption_key: [0xAB; 64], + }; + let payload = msg.encode_payload(); + // 4 + 9 + 1 + 4 + 64 = 82 + assert_eq!(payload.len(), 82); + assert_eq!(&payload[4..13], b"8.1.1.126"); + assert_eq!(payload[13], 0); + assert_eq!(&payload[18..82], &[0xAB; 64]); + } + + #[test] + fn test_startup_response_error() { + let mut data = [0u8; 64]; + data[8] = 28; // msg_len + let msg = b"Fail to establish connection"; + data[12..12 + msg.len()].copy_from_slice(msg); + let resp = StartupResponse::from_bytes(&data, -6003).unwrap(); + assert_eq!(resp.response_code, -6003); + assert_eq!(resp.encoding, 0); + assert_eq!(resp.challenge.len(), 0); + } + + #[test] + fn test_startup_response_invalid_version() { + let mut data = [0u8; 64]; + data[8] = 20; + let msg = b"Invalid client version"; + data[12..12 + msg.len()].copy_from_slice(msg); + let resp = StartupResponse::from_bytes(&data, -118).unwrap(); + assert_eq!(resp.response_code, -118); + assert!(resp.server_version.contains("Invalid")); + } + + #[test] + fn test_startup_response_success() { + let mut data = [0u8; 112]; + // 16 bytes of zeros (reserved) + // Server version length at offset 16 + let ver = b"8.1.3.62"; + data[16] = ver.len() as u8; + data[20..20 + ver.len()].copy_from_slice(ver); + // Sentinel at offset 28: -1 + data[28] = 0xFF; + data[29] = 0xFF; + data[30] = 0xFF; + data[31] = 0xFF; + // Key length at offset 32: 64 + data[32] = 64; + // Challenge bytes at offset 36 + for i in 0..48 { + data[36 + i] = 0xBB; + } + + let resp = StartupResponse::from_bytes(&data, 0).unwrap(); + assert_eq!(resp.server_version, "8.1.3.62"); + assert_eq!(resp.response_code, 0); + } + + #[test] + fn test_startup_key_not_all_zeros() { + let msg = StartupMessage::new(); + // Key should not be all zeros + let non_zero_count = msg.encryption_key.iter().filter(|&&b| b != 0).count(); + assert!(non_zero_count > 0, "encryption key should not be all zeros"); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-protocol/src/message/transaction.rs b/Native/DamengBridge/Vendor/dameng-protocol/src/message/transaction.rs new file mode 100644 index 000000000..92556d3d6 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-protocol/src/message/transaction.rs @@ -0,0 +1,74 @@ +//! Transaction control messages: COMMIT (type 8) and ROLLBACK (type 9). + +use bytes::BytesMut; + +/// Client->Server COMMIT message (type 8). +/// +/// Commits the current transaction. +#[derive(Debug, Clone)] +pub struct CommitMessage; + +impl CommitMessage { + /// Encode to payload bytes (empty payload). + pub fn encode_payload(&self) -> BytesMut { + BytesMut::new() + } +} + +/// Client->Server ROLLBACK message (type 9). +/// +/// Rolls back the current transaction. +#[derive(Debug, Clone)] +pub struct RollbackMessage; + +impl RollbackMessage { + /// Encode to payload bytes (empty payload). + pub fn encode_payload(&self) -> BytesMut { + BytesMut::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_commit_encode_empty() { + let commit = CommitMessage; + let payload = commit.encode_payload(); + assert!(payload.is_empty()); + } + + #[test] + fn test_rollback_encode_empty() { + let rollback = RollbackMessage; + let payload = rollback.encode_payload(); + assert!(payload.is_empty()); + } + + #[test] + fn test_commit_debug() { + let commit = CommitMessage; + let debug_str = format!("{:?}", commit); + assert!(debug_str.contains("CommitMessage")); + } + + #[test] + fn test_rollback_debug() { + let rollback = RollbackMessage; + let debug_str = format!("{:?}", rollback); + assert!(debug_str.contains("RollbackMessage")); + } + + #[test] + fn test_commit_clone() { + let commit = CommitMessage; + let _cloned = commit.clone(); + } + + #[test] + fn test_rollback_clone() { + let rollback = RollbackMessage; + let _cloned = rollback.clone(); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-types/Cargo.toml b/Native/DamengBridge/Vendor/dameng-types/Cargo.toml new file mode 100644 index 000000000..9bd95fab0 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dameng-types" +version = "0.1.0" +edition = "2021" +description = "Dameng database type definitions and conversions" +license = "MIT" +repository = "https://github.com/rarnu/rust-dameng" +keywords = ["dameng", "database", "types"] +categories = ["database"] + +[dependencies] +bytes = "1" +chrono = "0.4" +rust_decimal = "1" +encoding_rs = "0.8" diff --git a/Native/DamengBridge/Vendor/dameng-types/LICENSE.txt b/Native/DamengBridge/Vendor/dameng-types/LICENSE.txt new file mode 100644 index 000000000..81121d744 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-types/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 指令集 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Native/DamengBridge/Vendor/dameng-types/src/encoding.rs b/Native/DamengBridge/Vendor/dameng-types/src/encoding.rs new file mode 100644 index 000000000..b4d49da37 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-types/src/encoding.rs @@ -0,0 +1,169 @@ +//! Encoding conversion between server encoding (UTF-8 / GB18030) and Rust UTF-8. +//! +//! DM server may use different character encodings. The encoding is determined +//! from the LOGIN_RESPONSE (type=163): +//! - 1: UTF-8 +//! - 2: GB18030 +//! +//! All string data sent to the server must be encoded in the server's encoding. +//! All string data received from the server must be decoded from the server's +//! encoding to Rust's native UTF-8. + +use encoding_rs::{Encoding, GB18030, UTF_8}; + +/// Server-side encoding determined from LOGIN_RESPONSE. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerEncoding { + /// UTF-8 encoding (encoding value = 1). + Utf8, + /// GB18030 encoding (encoding value = 2). + Gb18030, +} + +impl ServerEncoding { + /// Create from DM protocol encoding value. + /// + /// Per DM Go driver (dm_build_425), the LOGIN_RESPONSE encoding field maps: + /// - 0: GB18030 + /// - 1: UTF-8 + /// - 2: EUC-KR + /// Defaults to GB18030 (matching DM Go driver behavior). + pub fn from_protocol_value(value: u8) -> Self { + match value { + 1 => ServerEncoding::Utf8, + _ => ServerEncoding::Gb18030, // 0, 2, and default → GB18030 + } + } + /// Get the encoding_rs Encoding instance for this server encoding. + pub fn encoding(&self) -> &'static Encoding { + match self { + ServerEncoding::Utf8 => UTF_8, + ServerEncoding::Gb18030 => GB18030, + } + } +} + +/// Convert a UTF-8 string to the server's encoding. +/// +/// Used when sending SQL text or string parameters to the server. +/// If the server uses UTF-8, this is a no-op that returns the input as-is. +/// +/// # Arguments +/// * `server_encoding` - The server's character encoding +/// * `s` - The UTF-8 string to encode +/// +/// # Returns +/// Bytes in the server's encoding. +pub fn encode_to_server(server_encoding: ServerEncoding, s: &str) -> Vec { + let enc = server_encoding.encoding(); + let (result, _, had_errors) = enc.encode(s); + if had_errors { + // Fallback: should not happen for valid UTF-8 input + // If encoding fails, return the raw UTF-8 bytes as a last resort + return s.as_bytes().to_vec(); + } + result.to_vec() +} + +/// Convert bytes from the server's encoding to a UTF-8 String. +/// +/// Used when receiving string data from the server (column values, +/// error messages, etc.). +/// If the server uses UTF-8, this is a no-op. +/// +/// # Arguments +/// * `server_encoding` - The server's character encoding +/// * `data` - Raw bytes from the server +/// +/// # Returns +/// A UTF-8 String. Invalid bytes are replaced with the Unicode +/// replacement character (U+FFFD), matching encoding_rs behavior. +pub fn decode_from_server(server_encoding: ServerEncoding, data: &[u8]) -> String { + let enc = server_encoding.encoding(); + let (result, _, _) = enc.decode(data); + result.into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_protocol_value_utf8() { + assert_eq!(ServerEncoding::from_protocol_value(1), ServerEncoding::Utf8); + } + + #[test] + fn test_from_protocol_value_gb18030() { + assert_eq!( + ServerEncoding::from_protocol_value(0), + ServerEncoding::Gb18030 + ); + assert_eq!( + ServerEncoding::from_protocol_value(2), + ServerEncoding::Gb18030 + ); + } + + #[test] + fn test_from_protocol_value_default() { + assert_eq!( + ServerEncoding::from_protocol_value(255), + ServerEncoding::Gb18030 + ); + } + + #[test] + fn test_utf8_roundtrip() { + let s = "Hello, 世界!"; + let encoded = encode_to_server(ServerEncoding::Utf8, s); + let decoded = decode_from_server(ServerEncoding::Utf8, &encoded); + assert_eq!(decoded, s); + } + + #[test] + fn test_gb18030_roundtrip() { + let s = "达梦数据库测试"; + let encoded = encode_to_server(ServerEncoding::Gb18030, s); + let decoded = decode_from_server(ServerEncoding::Gb18030, &encoded); + assert_eq!(decoded, s); + } + + #[test] + fn test_gb18030_ascii_passthrough() { + let s = "SELECT * FROM TABLE"; + let encoded = encode_to_server(ServerEncoding::Gb18030, s); + // ASCII should be identical in GB18030 + assert_eq!(encoded, s.as_bytes()); + let decoded = decode_from_server(ServerEncoding::Gb18030, &encoded); + assert_eq!(decoded, s); + } + + #[test] + fn test_gb18030_chinese() { + // These Chinese characters should encode to multi-byte GB18030 + let s = "中文"; + let encoded = encode_to_server(ServerEncoding::Gb18030, s); + // GB18030 encoding of Chinese chars is multi-byte + assert!(encoded.len() > s.len().min(2)); + let decoded = decode_from_server(ServerEncoding::Gb18030, &encoded); + assert_eq!(decoded, s); + } + + #[test] + fn test_empty_string() { + let encoded = encode_to_server(ServerEncoding::Gb18030, ""); + assert!(encoded.is_empty()); + let decoded = decode_from_server(ServerEncoding::Gb18030, &[]); + assert!(decoded.is_empty()); + } + + #[test] + fn test_invalid_gb18030_fallback() { + // Invalid bytes in GB18030 should produce replacement chars + let invalid_data = vec![0xFF, 0xFE, 0xFF, 0xFE]; + let decoded = decode_from_server(ServerEncoding::Gb18030, &invalid_data); + // encoding_rs replaces invalid bytes with U+FFFD + assert!(!decoded.is_empty()); + } +} diff --git a/Native/DamengBridge/Vendor/dameng-types/src/lib.rs b/Native/DamengBridge/Vendor/dameng-types/src/lib.rs new file mode 100644 index 000000000..56a26cfb1 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng-types/src/lib.rs @@ -0,0 +1,1469 @@ +//! Dameng database type definitions and conversions. +//! +//! This crate provides type mappings between Dameng database types +//! and Rust native types, along with encoding/decoding utilities. + +use std::str::FromStr; + +pub mod encoding; +pub use encoding::{decode_from_server, encode_to_server, ServerEncoding}; + +/// Dameng SQL value type enum. +/// +/// Maps DM type codes to Rust types for encoding and decoding. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DmValueType { + BIT, // 1 + TINYINT, // 2 + VARCHAR, // 3 + INT, // 4 + BIGINT, // 5 + SMALLINT, // 6 + FLOAT, // 7 + DOUBLE, // 8 + DECIMAL, // 9 + DATE, // 10 + TIME, // 11 + TIMESTAMP, // 12 + BLOB, // 13 + CLOB, // 14 + INTERVAL, // 15 + CHAR, // 16 + BINARY, // 17 + VARBINARY, // 18 + NUMERIC, // 20 - alias for DECIMAL + BOOLEAN, // 21 - alias for BIT + DATETIME, // 22 - alias for TIMESTAMP + VARCHAR2, // 23 - alias for VARCHAR + DATETIME2, // 24 - alias for TIMESTAMP + TIME_TZ, // 25 - time with time zone + DATETIME_TZ, // 26 - timestamp with time zone + INTERVAL_YM, // 27 - interval year to month + INTERVAL_DT, // 28 - interval day to second + RAW, // 29 - alias for BINARY + DATETIME2_TZ, // 30 - timestamp2 with time zone + REAL, // 31 - alias for FLOAT +} + +impl DmValueType { + /// Create a DmValueType from a DM type code. + pub fn from_type_code(code: i32) -> Option { + match code { + 1 => Some(DmValueType::BIT), + 2 => Some(DmValueType::TINYINT), + 3 => Some(DmValueType::VARCHAR), + 4 => Some(DmValueType::INT), + 5 => Some(DmValueType::BIGINT), + 6 => Some(DmValueType::SMALLINT), + 7 => Some(DmValueType::FLOAT), + 8 => Some(DmValueType::DOUBLE), + 9 => Some(DmValueType::DECIMAL), + 10 => Some(DmValueType::DATE), + 11 => Some(DmValueType::TIME), + 12 => Some(DmValueType::TIMESTAMP), + 13 => Some(DmValueType::BLOB), + 14 => Some(DmValueType::CLOB), + 15 => Some(DmValueType::INTERVAL), + 16 => Some(DmValueType::CHAR), + 17 => Some(DmValueType::BINARY), + 18 => Some(DmValueType::VARBINARY), + 20 => Some(DmValueType::NUMERIC), + 21 => Some(DmValueType::BOOLEAN), + 22 => Some(DmValueType::DATETIME), + 23 => Some(DmValueType::VARCHAR2), + 24 => Some(DmValueType::DATETIME2), + 25 => Some(DmValueType::TIME_TZ), + 26 => Some(DmValueType::DATETIME_TZ), + 27 => Some(DmValueType::INTERVAL_YM), + 28 => Some(DmValueType::INTERVAL_DT), + 29 => Some(DmValueType::RAW), + 30 => Some(DmValueType::DATETIME2_TZ), + 31 => Some(DmValueType::REAL), + _ => None, + } + } + + /// Get the DM type code for this value type. + pub fn type_code(self) -> i32 { + match self { + DmValueType::BIT => 1, + DmValueType::TINYINT => 2, + DmValueType::VARCHAR => 3, + DmValueType::INT => 4, + DmValueType::BIGINT => 5, + DmValueType::SMALLINT => 6, + DmValueType::FLOAT => 7, + DmValueType::DOUBLE => 8, + DmValueType::DECIMAL => 9, + DmValueType::DATE => 10, + DmValueType::TIME => 11, + DmValueType::TIMESTAMP => 12, + DmValueType::BLOB => 13, + DmValueType::CLOB => 14, + DmValueType::INTERVAL => 15, + DmValueType::CHAR => 16, + DmValueType::BINARY => 17, + DmValueType::VARBINARY => 18, + DmValueType::NUMERIC => 20, + DmValueType::BOOLEAN => 21, + DmValueType::DATETIME => 22, + DmValueType::VARCHAR2 => 23, + DmValueType::DATETIME2 => 24, + DmValueType::TIME_TZ => 25, + DmValueType::DATETIME_TZ => 26, + DmValueType::INTERVAL_YM => 27, + DmValueType::INTERVAL_DT => 28, + DmValueType::RAW => 29, + DmValueType::DATETIME2_TZ => 30, + DmValueType::REAL => 31, + } + } + + /// Get the type name string for protocol messages. + pub fn type_name(self) -> &'static str { + match self { + DmValueType::BIT => "BIT", + DmValueType::TINYINT => "TINYINT", + DmValueType::VARCHAR => "VARCHAR", + DmValueType::INT => "INT", + DmValueType::BIGINT => "BIGINT", + DmValueType::SMALLINT => "SMALLINT", + DmValueType::FLOAT => "FLOAT", + DmValueType::DOUBLE => "DOUBLE", + DmValueType::DECIMAL => "DECIMAL", + DmValueType::DATE => "DATE", + DmValueType::TIME => "TIME", + DmValueType::TIMESTAMP => "TIMESTAMP", + DmValueType::BLOB => "BLOB", + DmValueType::CLOB => "CLOB", + DmValueType::INTERVAL => "INTERVAL", + DmValueType::CHAR => "CHAR", + DmValueType::BINARY => "BINARY", + DmValueType::VARBINARY => "VARBINARY", + DmValueType::NUMERIC => "NUMERIC", + DmValueType::BOOLEAN => "BOOLEAN", + DmValueType::DATETIME => "DATETIME", + DmValueType::VARCHAR2 => "VARCHAR2", + DmValueType::DATETIME2 => "DATETIME2", + DmValueType::TIME_TZ => "TIME_TZ", + DmValueType::DATETIME_TZ => "DATETIME_TZ", + DmValueType::INTERVAL_YM => "INTERVAL_YM", + DmValueType::INTERVAL_DT => "INTERVAL_DT", + DmValueType::RAW => "RAW", + DmValueType::DATETIME2_TZ => "DATETIME2_TZ", + DmValueType::REAL => "REAL", + } + } +} + +/// A decoded DM value. +#[derive(Debug, Clone, PartialEq)] +pub enum DmValue { + Null, + Boolean(bool), + TinyInt(i8), + SmallInt(i16), + Int(i32), + BigInt(i64), + Float(f32), + Double(f64), + Text(String), + Bytea(Vec), + Decimal(rust_decimal::Decimal), + /// DATE value (chrono::NaiveDate). + Date(chrono::NaiveDate), + /// TIME value (chrono::NaiveTime). + Time(chrono::NaiveTime), + /// TIMESTAMP / DATETIME value (chrono::NaiveDateTime). + Timestamp(chrono::NaiveDateTime), + /// LOB_LOCATOR: DM server returns a 16-byte locator handle when CLOB/BLOB + /// data exceeds 2048 bytes. The actual content must be fetched via LOBREAD + /// protocol messages. This variant stores the raw 16-byte locator. + LobLocator(LobLocator), +} + +/// A LOB (Large Object) locator returned by the DM server. +/// +/// When CLOB/BLOB data exceeds 2048 bytes, DM returns a 16-byte locator +/// instead of the actual data. The locator contains server-side pointers +/// (table ID, column ID, row ID, group/file/page numbers) that can be +/// used with LOBREAD protocol messages to fetch the actual content. +#[derive(Debug, Clone, PartialEq)] +pub struct LobLocator { + /// Raw NBLOB_HEAD bytes from DM server (may be >16 for new LOB format). + pub raw: Vec, + /// Whether this is a CLOB (true) or BLOB (false). + pub is_clob: bool, + /// Table ID from column metadata or NBLOB_HEAD extended section. + /// Used by LOBREAD protocol to locate the LOB data on the server. + pub tab_id: i32, + /// Column ID from column metadata. + /// Used by LOBREAD protocol to locate the LOB data on the server. + pub col_id: i16, + /// Current file ID for LOBREAD cursor tracking. Updated after each read. + pub cur_file_id: i16, + /// Current page number for LOBREAD cursor tracking. Updated after each read. + pub cur_page_no: i32, + /// Accumulated offset for LOBREAD cursor tracking. Updated after each read. + pub total_offset: i32, +} + +#[allow(unused)] +impl LobLocator { + /// NBLOB_HEAD offsets (matching dm_go constants). + /// NBLOB_HEAD_IN_ROW_FLAG = 0 (1 byte) + /// NBLOB_HEAD_BLOBID = 1 (8 bytes) + /// NBLOB_HEAD_BLOB_LEN = 9 (4 bytes) + /// NBLOB_HEAD_OUTROW_GROUPID = 13 (2 bytes - USINT) + /// NBLOB_HEAD_OUTROW_FILEID = 15 (2 bytes - USINT) + /// NBLOB_HEAD_OUTROW_PAGENO = 17 (4 bytes - ULINT) + /// NBLOB_EX_HEAD_TABLE_ID = 21 (4 bytes - ULINT) + /// NBLOB_EX_HEAD_COL_ID = 25 (2 bytes - USINT) + /// NBLOB_EX_HEAD_ROW_ID = 27 (8 bytes - DDWORD) + /// NBLOB_EX_HEAD_FPA_GRPID = 35 (2 bytes - USINT) + /// NBLOB_EX_HEAD_FPA_FILEID = 37 (2 bytes - USINT) + /// NBLOB_EX_HEAD_FPA_PAGENO = 39 (4 bytes - ULINT) + const IN_ROW_FLAG: usize = 0; + const BLOBID: usize = 1; + const BLOB_LEN: usize = 9; + const GROUPID: usize = 13; + const FILEID: usize = 15; + const PAGENO: usize = 17; + const EX_TABLE_ID: usize = 21; + const EX_COL_ID: usize = 25; + const EX_ROW_ID: usize = 27; + const EX_FPA_GRPID: usize = 35; + const EX_FPA_FILEID: usize = 37; + const EX_FPA_PAGENO: usize = 39; + + /// Create a LOB locator from NBLOB_HEAD raw bytes returned by DM server. + /// + /// NBLOB_HEAD layout (out-of-row): + /// - Off 0: in_row_flag (1 byte, 0x02 = out-of-row) + /// - Off 1: blob_id (8 bytes LE i64) + /// - Off 9: group_id (2 bytes LE i16) + /// - Off 11: file_id (2 bytes LE i16) + /// - Off 13: page_no (4 bytes LE i32) + /// - Off 17: (extended section if present) + /// - Off 21: tab_id (4 bytes LE i32) + /// - Off 25: col_id (2 bytes LE i16) + /// - Off 27: row_id (8 bytes LE i64) + /// + /// tab_id and col_id can also come from the column metadata in the + /// EXEC_RESPONSE header (parsed separately), in which case use + /// `with_tab_col_id()` to set them. + pub fn from_nblob_head(data: Vec, is_clob: bool) -> Self { + let mut tab_id = 0; + let mut col_id = 0; + + // Try to extract tab_id/col_id from extended NBLOB_HEAD section + if data.len() >= 29 { + tab_id = i32::from_le_bytes([ + data[Self::EX_TABLE_ID], + data[Self::EX_TABLE_ID + 1], + data[Self::EX_TABLE_ID + 2], + data[Self::EX_TABLE_ID + 3], + ]); + col_id = i16::from_le_bytes([data[Self::EX_COL_ID], data[Self::EX_COL_ID + 1]]); + } + + Self { + raw: data, + is_clob, + tab_id, + col_id, + cur_file_id: 0, + cur_page_no: 0, + total_offset: 0, + } + } + + /// Set tab_id/col_id from column metadata (overrides NBLOB_HEAD values). + /// This is called by the response parser after reading the column header. + pub fn with_tab_col_id(mut self, tab_id: i32, col_id: i16) -> Self { + self.tab_id = tab_id; + self.col_id = col_id; + self + } + + /// Get the lob_flag value: 0 = BLOB (byte), 1 = CLOB (char). + pub fn lob_flag(&self) -> u8 { + if self.is_clob { + 1 + } else { + 0 + } + } + + /// Get the blob_id from the NBLOB_HEAD format (offset 1, 8 bytes LE). + pub fn blob_id(&self) -> i64 { + if self.raw.len() >= Self::BLOBID + 8 { + let bytes: [u8; 8] = self.raw[Self::BLOBID..Self::BLOBID + 8].try_into().unwrap(); + i64::from_le_bytes(bytes) + } else { + 0 + } + } + + /// Get the group ID for out-of-row locators (offset 13, 2 bytes LE i16). + pub fn group_id(&self) -> i16 { + if self.raw.len() >= Self::GROUPID + 2 { + i16::from_le_bytes([self.raw[Self::GROUPID], self.raw[Self::GROUPID + 1]]) + } else { + -1 + } + } + + /// Get the file ID for out-of-row locators (offset 15, 2 bytes LE i16). + pub fn file_id(&self) -> i16 { + if self.raw.len() >= Self::FILEID + 2 { + i16::from_le_bytes([self.raw[Self::FILEID], self.raw[Self::FILEID + 1]]) + } else { + -1 + } + } + + /// Get the page number for out-of-row locators (offset 17, 4 bytes LE i32). + pub fn page_no(&self) -> i32 { + if self.raw.len() >= Self::PAGENO + 4 { + let bytes: [u8; 4] = self.raw[Self::PAGENO..Self::PAGENO + 4].try_into().unwrap(); + i32::from_le_bytes(bytes) + } else { + -1 + } + } + + /// Get the row_id from extended section (offset 27, 8 bytes LE i64). + pub fn row_id(&self) -> i64 { + if self.raw.len() >= Self::EX_ROW_ID + 8 { + let bytes: [u8; 8] = self.raw[Self::EX_ROW_ID..Self::EX_ROW_ID + 8] + .try_into() + .unwrap(); + i64::from_le_bytes(bytes) + } else { + 0 + } + } + + /// Get the extended group ID (offset 35, 2 bytes LE i16). + pub fn ex_group_id(&self) -> i16 { + if self.raw.len() >= Self::EX_FPA_GRPID + 2 { + i16::from_le_bytes([ + self.raw[Self::EX_FPA_GRPID], + self.raw[Self::EX_FPA_GRPID + 1], + ]) + } else { + 0 + } + } + + /// Get the extended file ID (offset 37, 2 bytes LE i16). + pub fn ex_file_id(&self) -> i16 { + if self.raw.len() >= Self::EX_FPA_FILEID + 2 { + i16::from_le_bytes([ + self.raw[Self::EX_FPA_FILEID], + self.raw[Self::EX_FPA_FILEID + 1], + ]) + } else { + 0 + } + } + + /// Get the extended page number (offset 39, 4 bytes LE i32). + pub fn ex_page_no(&self) -> i32 { + if self.raw.len() >= Self::EX_FPA_PAGENO + 4 { + let bytes: [u8; 4] = self.raw[Self::EX_FPA_PAGENO..Self::EX_FPA_PAGENO + 4] + .try_into() + .unwrap(); + i32::from_le_bytes(bytes) + } else { + 0 + } + } + + /// Check if extended section is present (NewLobFlag). + pub fn has_extended(&self) -> bool { + self.raw.len() >= Self::EX_TABLE_ID + 4 + } + + /// Update the cursor state from a LOBREAD response. + /// + /// After each LOBREAD, the server returns updated `curFileId`, `curPageNo`, + /// and `totalOffset` values. This method updates the locator so subsequent + /// reads continue from the correct position. + /// + /// This is a mutable reference — clone the locator before calling this + /// if you need to preserve the original. + pub fn update_cursor(&mut self, cur_file_id: i16, cur_page_no: i32, total_offset: i32) { + self.cur_file_id = cur_file_id; + self.cur_page_no = cur_page_no; + self.total_offset = total_offset; + } + + /// Initialize cursor from the initial LOB locator values. + /// + /// On the first read, `curFileId` = `fileId` and `curPageNo` = `pageNo`. + pub fn init_cursor(&mut self) { + self.cur_file_id = self.file_id(); + self.cur_page_no = self.page_no(); + self.total_offset = 0; + } +} + +impl From for DmValue { + fn from(v: i32) -> Self { + DmValue::Int(v) + } +} + +impl From for DmValue { + fn from(v: i64) -> Self { + DmValue::BigInt(v) + } +} + +impl From for DmValue { + fn from(v: String) -> Self { + DmValue::Text(v) + } +} + +impl From<&str> for DmValue { + fn from(v: &str) -> Self { + DmValue::Text(v.to_string()) + } +} + +impl From for DmValue { + fn from(v: bool) -> Self { + DmValue::Boolean(v) + } +} + +impl From for DmValue { + fn from(v: f64) -> Self { + DmValue::Double(v) + } +} + +impl From> for DmValue { + fn from(v: Vec) -> Self { + DmValue::Bytea(v) + } +} + +// --- Option From impls --- + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::TinyInt).unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::SmallInt).unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::Int).unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::BigInt).unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::Float).unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::Double).unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::Text).unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option<&str>) -> Self { + v.map(|s| s.to_string()) + .map(DmValue::Text) + .unwrap_or(DmValue::Null) + } +} + +impl From> for DmValue { + fn from(v: Option) -> Self { + v.map(DmValue::Boolean).unwrap_or(DmValue::Null) + } +} + +impl From>> for DmValue { + fn from(v: Option>) -> Self { + v.map(DmValue::Bytea).unwrap_or(DmValue::Null) + } +} + +/// Trait for dynamic parameter binding — SQLx-style `&[&dyn ToDmValue]` support. +/// +/// # Example +/// +/// ```ignore +/// let name = "Alice"; +/// let age: i32 = 30; +/// let rows = client.query_with_params( +/// "SELECT * FROM person WHERE name = ? AND age > ?", +/// &[&name, &age], +/// )?; +/// ``` +pub trait ToDmValue { + /// Convert this value into a `DmValue`. + fn to_dm_value(&self) -> DmValue; +} + +// --- ToDmValue implementations for concrete types --- + +macro_rules! impl_to_dm_value { + ($($ty:ty => $variant:ident),* $(,)?) => { + $( + impl ToDmValue for $ty { + fn to_dm_value(&self) -> DmValue { + DmValue::$variant(*self) + } + } + )* + }; +} + +impl_to_dm_value!( + bool => Boolean, + i8 => TinyInt, + i16 => SmallInt, + i32 => Int, + i64 => BigInt, + f32 => Float, + f64 => Double, +); + +impl ToDmValue for u8 { + fn to_dm_value(&self) -> DmValue { + DmValue::TinyInt(*self as i8) + } +} + +impl ToDmValue for u16 { + fn to_dm_value(&self) -> DmValue { + DmValue::SmallInt(*self as i16) + } +} + +impl ToDmValue for u32 { + fn to_dm_value(&self) -> DmValue { + if *self <= i32::MAX as u32 { + DmValue::Int(*self as i32) + } else { + DmValue::BigInt(*self as i64) + } + } +} + +impl ToDmValue for u64 { + fn to_dm_value(&self) -> DmValue { + if *self <= i64::MAX as u64 { + DmValue::BigInt(*self as i64) + } else { + DmValue::Text(self.to_string()) + } + } +} + +// Blanket impl: `&T` where `T: ToDmValue` delegates to T. +// This lets `&[&id, &name]` work when `name: &str` (producing `&&str`). +impl ToDmValue for &T { + fn to_dm_value(&self) -> DmValue { + T::to_dm_value(*self) + } +} + +impl ToDmValue for str { + fn to_dm_value(&self) -> DmValue { + DmValue::Text(self.to_string()) + } +} + +impl ToDmValue for String { + fn to_dm_value(&self) -> DmValue { + DmValue::Text(self.clone()) + } +} + +impl ToDmValue for [u8] { + fn to_dm_value(&self) -> DmValue { + DmValue::Bytea(self.to_vec()) + } +} + +impl ToDmValue for Vec { + fn to_dm_value(&self) -> DmValue { + DmValue::Bytea(self.clone()) + } +} + +// --- Option implementations --- + +macro_rules! impl_option_to_dm_value { + ($($ty:ty),* $(,)?) => { + $( + impl ToDmValue for Option<$ty> { + fn to_dm_value(&self) -> DmValue { + match self { + Some(v) => v.to_dm_value(), + None => DmValue::Null, + } + } + } + )* + }; +} + +impl_option_to_dm_value!(bool, i8, i16, i32, i64, f32, f64, String); +impl_option_to_dm_value!(rust_decimal::Decimal); +impl_option_to_dm_value!(chrono::NaiveDate); +impl_option_to_dm_value!(chrono::NaiveDateTime); + +impl ToDmValue for Option<&str> { + fn to_dm_value(&self) -> DmValue { + self.map(|s| s.to_string()) + .map(DmValue::Text) + .unwrap_or(DmValue::Null) + } +} + +impl ToDmValue for Option> { + fn to_dm_value(&self) -> DmValue { + self.clone().map(DmValue::Bytea).unwrap_or(DmValue::Null) + } +} + +// --- ToDmValue for chrono / rust_decimal types --- + +impl ToDmValue for rust_decimal::Decimal { + fn to_dm_value(&self) -> DmValue { + DmValue::Decimal(*self) + } +} + +impl ToDmValue for chrono::NaiveDate { + fn to_dm_value(&self) -> DmValue { + DmValue::Date(*self) + } +} + +impl ToDmValue for chrono::NaiveTime { + fn to_dm_value(&self) -> DmValue { + DmValue::Time(*self) + } +} + +impl ToDmValue for chrono::NaiveDateTime { + fn to_dm_value(&self) -> DmValue { + DmValue::Timestamp(*self) + } +} + +/// Encode a Rust value to DM protocol bytes. +pub fn encode_value(ty: DmValueType, value: &DmValue) -> Vec { + match ty { + DmValueType::INT => { + if let DmValue::Int(v) = value { + v.to_le_bytes().to_vec() + } else { + vec![0; 4] + } + } + DmValueType::BIGINT => { + if let DmValue::BigInt(v) = value { + v.to_le_bytes().to_vec() + } else { + vec![0; 8] + } + } + DmValueType::SMALLINT => { + if let DmValue::SmallInt(v) = value { + v.to_le_bytes().to_vec() + } else { + vec![0; 2] + } + } + DmValueType::FLOAT | DmValueType::REAL => { + // Always 4 bytes + if let DmValue::Float(v) = value { + v.to_le_bytes().to_vec() + } else if let DmValue::Double(v) = value { + (*v as f32).to_le_bytes().to_vec() + } else { + vec![0u8; 4] + } + } + DmValueType::DOUBLE => { + // Always 8 bytes + if let DmValue::Double(v) = value { + v.to_le_bytes().to_vec() + } else if let DmValue::Float(v) = value { + (*v as f64).to_le_bytes().to_vec() + } else { + vec![0u8; 8] + } + } + DmValueType::BIT | DmValueType::BOOLEAN => { + if let DmValue::Boolean(v) = value { + vec![if *v { 1 } else { 0 }] + } else { + vec![0] + } + } + DmValueType::VARCHAR | DmValueType::CHAR | DmValueType::CLOB | DmValueType::VARCHAR2 => { + if let DmValue::Text(v) = value { + v.as_bytes().to_vec() + } else { + vec![] + } + } + DmValueType::BLOB | DmValueType::BINARY | DmValueType::VARBINARY | DmValueType::RAW => { + if let DmValue::Bytea(v) = value { + v.clone() + } else { + vec![] + } + } + DmValueType::DECIMAL | DmValueType::NUMERIC => { + if let DmValue::Decimal(v) = value { + v.to_string().as_bytes().to_vec() + } else { + vec![] + } + } + DmValueType::TINYINT => { + if let DmValue::TinyInt(v) = value { + v.to_le_bytes().to_vec() + } else { + vec![0] + } + } + DmValueType::DATE + | DmValueType::TIME + | DmValueType::TIMESTAMP + | DmValueType::DATETIME + | DmValueType::DATETIME2 + | DmValueType::TIME_TZ + | DmValueType::DATETIME_TZ + | DmValueType::DATETIME2_TZ => { + if let DmValue::Date(d) = value { + d.format("%Y-%m-%d").to_string().as_bytes().to_vec() + } else if let DmValue::Time(t) = value { + t.format("%H:%M:%S").to_string().as_bytes().to_vec() + } else if let DmValue::Timestamp(ts) = value { + ts.format("%Y-%m-%d %H:%M:%S") + .to_string() + .as_bytes() + .to_vec() + } else if let DmValue::Text(v) = value { + v.as_bytes().to_vec() + } else { + vec![] + } + } + // Generic INTERVAL (type_code=15) — send as text, server parses it. + DmValueType::INTERVAL => { + if let DmValue::Text(v) = value { + v.as_bytes().to_vec() + } else { + vec![] + } + } + // INTERVAL_YM (type_code=27): year-month interval, 12 bytes. + // Binary layout: year(LE i32, 4) + month(LE i32, 4) + padding(4). + // Text input: "Y-M" (e.g., "1-2" = 1 year 2 months) or just "Y". + DmValueType::INTERVAL_YM => { + if let DmValue::Text(v) = value { + encode_interval_ym(v) + } else { + vec![0; 12] + } + } + // INTERVAL_DT (type_code=28): day-time interval, 24 bytes. + // Binary layout: day(LE i32, 4) + hour(LE i32, 4) + minute(LE i32, 4) + // + second(LE i32, 4) + nanoseconds(LE i64, 8). + // Text input: "D HH:MI:SS.FF" (e.g., "1 2:3:4.5" = 1 day, 2h 3m 4.5s) + // or "HH:MI:SS.FF" (day defaults to 0). + DmValueType::INTERVAL_DT => { + if let DmValue::Text(v) = value { + encode_interval_dt(v) + } else { + vec![0; 24] + } + } + } +} + +/// Encode an INTERVAL YEAR TO MONTH text string to DM binary format (12 bytes). +/// +/// Binary layout: year(LE i32, 4) + month(LE i32, 4) + padding(4 zero bytes). +/// +/// Accepted text formats: +/// - "Y-M" (e.g., "1-2" = 1 year 2 months) +/// - "+Y-M" or "-Y-M" for signed intervals +/// - "Y" (months default to 0) +fn encode_interval_ym(s: &str) -> Vec { + let mut year: i32 = 0; + let mut month: i32 = 0; + + let trimmed = s.trim(); + let (sign, rest) = if let Some(stripped) = trimmed.strip_prefix('+') { + (1i32, stripped.trim()) + } else if let Some(stripped) = trimmed.strip_prefix('-') { + (-1, stripped.trim()) + } else { + (1, trimmed) + }; + + if let Some((y_str, m_str)) = rest.split_once('-') { + if let Ok(y) = y_str.trim().parse::() { + year = y; + } + if let Ok(m) = m_str.trim().parse::() { + month = m; + } + } else if let Ok(y) = rest.trim().parse::() { + year = y; + } + + let mut buf = Vec::with_capacity(12); + buf.extend_from_slice(&(year * sign).to_le_bytes()); + buf.extend_from_slice(&(month * sign).to_le_bytes()); + buf.extend_from_slice(&[0, 0, 0, 0]); + buf +} + +/// Encode an INTERVAL DAY TO SECOND text string to DM binary format (24 bytes). +/// +/// Binary layout: day(LE i32, 4) + hour(LE i32, 4) + minute(LE i32, 4) +/// + second(LE i32, 4) + nanoseconds(LE i64, 8). +/// +/// Accepted text formats: +/// - "D HH:MI:SS.FF" (e.g., "1 2:3:4.5" = 1 day, 2h 3m 4.5s) +/// - "HH:MI:SS.FF" (day defaults to 0) +/// - "+D HH:MI:SS.FF" or "-D HH:MI:SS.FF" for signed intervals +fn encode_interval_dt(s: &str) -> Vec { + let mut day: i32 = 0; + let mut hour: i32 = 0; + let mut minute: i32 = 0; + let mut second: i32 = 0; + let mut nanosecond: i64 = 0; + + let trimmed = s.trim(); + let (sign, rest) = if let Some(stripped) = trimmed.strip_prefix('+') { + (1i32, stripped.trim()) + } else if let Some(stripped) = trimmed.strip_prefix('-') { + (-1, stripped.trim()) + } else { + (1, trimmed) + }; + + // Try "D HH:MI:SS.FF" format first + let rest_for_parse = if rest.contains(' ') { + // "D HH:MI:SS.FF" — extract day + if let Some((d_part, time_part)) = rest.split_once(' ') { + if let Ok(d) = d_part.trim().parse::() { + day = d; + } + time_part.trim() + } else { + rest + } + } else { + rest + }; + + // Parse HH:MI:SS.FF + if let Some((time, frac_str)) = rest_for_parse.split_once('.') { + let parts: Vec<&str> = time.split(':').collect(); + if parts.len() >= 3 { + if let Ok(h) = parts[0].parse::() { + hour = h; + } + if let Ok(m) = parts[1].parse::() { + minute = m; + } + if let Ok(s_val) = parts[2].parse::() { + second = s_val; + } + } else if parts.len() == 2 { + if let Ok(h) = parts[0].parse::() { + hour = h; + } + if let Ok(m) = parts[1].parse::() { + minute = m; + } + } + // Nanoseconds from fractional seconds (scale the fractional digits) + if let Ok(f) = frac_str.parse::() { + nanosecond = (f * 1_000_000_000.0) as i64; + } + } else { + let parts: Vec<&str> = rest_for_parse.split(':').collect(); + if parts.len() >= 3 { + if let Ok(h) = parts[0].parse::() { + hour = h; + } + if let Ok(m) = parts[1].parse::() { + minute = m; + } + if let Ok(s_val) = parts[2].parse::() { + second = s_val; + } + } else if parts.len() == 2 { + if let Ok(h) = parts[0].parse::() { + hour = h; + } + if let Ok(m) = parts[1].parse::() { + minute = m; + } + } + } + + let mut buf = Vec::with_capacity(24); + buf.extend_from_slice(&(day * sign).to_le_bytes()); + buf.extend_from_slice(&(hour * sign).to_le_bytes()); + buf.extend_from_slice(&(minute * sign).to_le_bytes()); + buf.extend_from_slice(&(second * sign).to_le_bytes()); + buf.extend_from_slice(&(nanosecond * sign as i64).to_le_bytes()); + buf +} + +/// Parse a raw output parameter value from the EXEC_RESPONSE frame. +/// +/// After executing a stored procedure with OUTPUT or INPUT_OUTPUT parameters, +/// this helper decodes the raw bytes returned by the server into a `DmValue` +/// based on the parameter's type code. +/// +/// # Arguments +/// * `bytes` - The raw bytes of the parameter value. +/// * `type_code` - The DM type code (e.g., 4 for INT, 3 for VARCHAR). +/// +/// # Returns +/// * `Some(DmValue)` if the value could be decoded. +/// * `None` if the type is unknown or the data is empty/invalid. +pub fn parse_output_param_value(bytes: &[u8], type_code: i32) -> Option { + if bytes.is_empty() { + return Some(DmValue::Null); + } + let ty = DmValueType::from_type_code(type_code)?; + decode_value(ty, bytes, None) +} + +/// Decode DM binary DECIMAL format (matches Go driver dm_go/o.go decodeDecimal). +fn decode_dm_binary_decimal(data: &[u8]) -> Option { + const FLAG_ZERO: u8 = 0x80; + const FLAG_POSITIVE: i32 = 0xC1; + const FLAG_NEGTIVE: i32 = 0x3E; + const NUM_POSITIVE: i32 = 1; + const NUM_NEGTIVE: i32 = 101; + + if data.is_empty() || data.len() > 21 { + return None; + } + if data[0] == FLAG_ZERO || data.len() == 1 { + return Some(rust_decimal::Decimal::ZERO); + } + let sign: i32 = if data[0] & FLAG_ZERO != 0 { 1 } else { -1 }; + let flag = data[0] as i32; + let _exp = if sign > 0 { + flag - FLAG_POSITIVE + } else { + FLAG_NEGTIVE - flag + }; + let mut sf = String::new(); + for &b in &data[1..] { + let digit = if sign > 0 { + b as i32 - NUM_POSITIVE + } else { + NUM_NEGTIVE - b as i32 + }; + if digit < 0 || digit > 99 { + break; + } + sf.push_str(&format!("{:02}", digit)); + } + if sf.is_empty() { + return None; + } + let int_val: i64 = sf.parse().ok()?; + Some(rust_decimal::Decimal::from_i128_with_scale( + int_val as i128, + 0, + )) +} + +/// Decode DM protocol bytes to a Rust value. +/// +/// # Arguments +/// * `ty` - The DM value type +/// * `data` - The raw bytes to decode +/// * `lob_meta` - Optional LOB column metadata (tab_id, col_id). Used to populate +/// the LobLocator when decoding out-of-row BLOB/CLOB values. +pub fn decode_value(ty: DmValueType, data: &[u8], lob_meta: Option<(i32, i16)>) -> Option { + if data.is_empty() { + return Some(DmValue::Null); + } + + match ty { + DmValueType::INT => { + if data.len() >= 4 { + let v = i32::from_le_bytes([data[0], data[1], data[2], data[3]]); + Some(DmValue::Int(v)) + } else { + None + } + } + DmValueType::BIGINT => { + if data.len() >= 8 { + let v = i64::from_le_bytes([ + data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], + ]); + Some(DmValue::BigInt(v)) + } else { + None + } + } + DmValueType::SMALLINT => { + if data.len() >= 2 { + let v = i16::from_le_bytes([data[0], data[1]]); + Some(DmValue::SmallInt(v)) + } else { + None + } + } + DmValueType::DOUBLE => { + if data.len() >= 8 { + let bytes: [u8; 8] = data[..8].try_into().ok()?; + let v = f64::from_le_bytes(bytes); + Some(DmValue::Double(v)) + } else { + None + } + } + DmValueType::FLOAT | DmValueType::REAL => { + if data.len() >= 4 { + let v = f32::from_le_bytes([data[0], data[1], data[2], data[3]]); + Some(DmValue::Float(v)) + } else { + None + } + } + DmValueType::BIT | DmValueType::BOOLEAN => Some(DmValue::Boolean(data[0] != 0)), + DmValueType::VARCHAR | DmValueType::CHAR | DmValueType::VARCHAR2 => { + String::from_utf8(data.to_vec()).ok().map(DmValue::Text) + } + DmValueType::CLOB => { + // DM returns NBLOB_HEAD format for CLOB values: + // - in_row=0x01: inline data follows (13-byte header: flag(1) + blob_id(8) + blob_len(4) + data) + // - in_row=0x02: out-of-row LOB locator (needs LOBREAD protocol) + // - legacy: exactly 16 bytes (old LOB_LOCATOR format) + if data.len() >= 13 && data[0] == 0x01 { + // Inline LOB data - extract actual content + let blob_len = if data.len() >= 13 { + u32::from_le_bytes([data[9], data[10], data[11], data[12]]) as usize + } else { + 0 + }; + if 13 + blob_len <= data.len() { + let inline_data = &data[13..13 + blob_len]; + String::from_utf8(inline_data.to_vec()) + .ok() + .map(DmValue::Text) + } else { + None + } + } else if data.len() >= 13 && data[0] == 0x02 { + // Out-of-row CLOB locator + let mut loc = LobLocator::from_nblob_head(data.to_vec(), true); + if let Some((tab_id, col_id)) = lob_meta { + loc = loc.with_tab_col_id(tab_id, col_id); + } + Some(DmValue::LobLocator(loc)) + } else if data.len() == 16 { + // Legacy 16-byte LOB_LOCATOR format + let mut loc = LobLocator::from_nblob_head(data.to_vec(), true); + if let Some((tab_id, col_id)) = lob_meta { + loc = loc.with_tab_col_id(tab_id, col_id); + } + Some(DmValue::LobLocator(loc)) + } else { + String::from_utf8(data.to_vec()).ok().map(DmValue::Text) + } + } + DmValueType::BINARY | DmValueType::VARBINARY | DmValueType::RAW => { + Some(DmValue::Bytea(data.to_vec())) + } + DmValueType::BLOB => { + // DM returns NBLOB_HEAD format for BLOB values (same as CLOB): + // - in_row=0x01: inline data follows + // - in_row=0x02: out-of-row LOB locator + if data.len() >= 13 && data[0] == 0x01 { + // Inline BLOB data + let blob_len = if data.len() >= 13 { + u32::from_le_bytes([data[9], data[10], data[11], data[12]]) as usize + } else { + 0 + }; + if 13 + blob_len <= data.len() { + Some(DmValue::Bytea(data[13..13 + blob_len].to_vec())) + } else { + None + } + } else if data.len() >= 13 && data[0] == 0x02 { + // Out-of-row BLOB locator + let mut loc = LobLocator::from_nblob_head(data.to_vec(), false); + if let Some((tab_id, col_id)) = lob_meta { + loc = loc.with_tab_col_id(tab_id, col_id); + } + Some(DmValue::LobLocator(loc)) + } else if data.len() == 16 { + // Legacy 16-byte LOB_LOCATOR format + let mut loc = LobLocator::from_nblob_head(data.to_vec(), false); + if let Some((tab_id, col_id)) = lob_meta { + loc = loc.with_tab_col_id(tab_id, col_id); + } + Some(DmValue::LobLocator(loc)) + } else { + Some(DmValue::Bytea(data.to_vec())) + } + } + DmValueType::DECIMAL | DmValueType::NUMERIC => { + // Try text format first (ASCII digits) + if let Ok(s) = std::str::from_utf8(data) { + if let Ok(d) = rust_decimal::Decimal::from_str(s.trim()) { + return Some(DmValue::Decimal(d)); + } + } + // Try DM binary DECIMAL format (matches Go driver o.go) + decode_dm_binary_decimal(data).map(DmValue::Decimal) + } + DmValueType::TINYINT => Some(DmValue::TinyInt(data[0] as i8)), + DmValueType::DATE + | DmValueType::TIME + | DmValueType::TIMESTAMP + | DmValueType::INTERVAL + | DmValueType::DATETIME + | DmValueType::DATETIME2 + | DmValueType::TIME_TZ + | DmValueType::DATETIME_TZ + | DmValueType::DATETIME2_TZ + | DmValueType::INTERVAL_YM + | DmValueType::INTERVAL_DT => { + // DM stores DATE/TIME/TIMESTAMP/INTERVAL as binary: + // DATE: 7 bytes (year:2BE, month:1, day:1, hour:1, min:1, sec:1) + // TIME: 6+ bytes (hour:1, min:1, sec:1, nanosec:4BE) + // TIMESTAMP: 11 bytes (year:2BE, month:1, day:1, hour:1, min:1, sec:1, nanosec:4BE) + // If data is valid UTF-8 text, pass through as Text. + // Otherwise decode binary to typed chrono variants. + if let Ok(s) = String::from_utf8(data.to_vec()) { + return Some(DmValue::Text(s)); + } + // Binary decode for TIMESTAMP / DATETIME + if ty == DmValueType::TIMESTAMP + || ty == DmValueType::DATETIME + || ty == DmValueType::DATETIME2 + { + // Try 11-byte OPE format + if data.len() >= 11 { + let year = u16::from_be_bytes([data[0], data[1]]) as i32; + let month = data[2] as u32; + let day = data[3] as u32; + let hour = data[4] as u32; + let min = data[5] as u32; + let sec = data[6] as u32; + let nano = u32::from_be_bytes([data[7], data[8], data[9], data[10]]); + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) + .and_then(|d| d.and_hms_nano_opt(hour, min, sec, nano)) + { + return Some(DmValue::Timestamp(d)); + } + } + // Try 8-byte DM row format + if data.len() >= 8 { + let year = i32::from(i16::from_le_bytes([data[0], data[1]])) & 0x7FFF; + let month = ((data[1] as u32 >> 7) & 0x1) + ((data[2] as u32 & 0x07) << 1); + let day = ((data[2] as u32 & 0xF8) >> 3) & 0x1F; + let hour = data[3] as u32 & 0x1F; + let min = ((data[3] as u32 >> 5) & 0x07) + ((data[4] as u32 & 0x07) << 3); + let sec = ((data[4] as u32 >> 3) & 0x1F) + ((data[5] as u32 & 0x01) << 5); + let nano = (((data[5] as u32 >> 1) & 0x7F) + + ((data[6] as u32 & 0xFF) << 7) + + ((data[7] as u32 & 0x1F) << 15)) + * 1000; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) + .and_then(|d| d.and_hms_nano_opt(hour, min, sec, nano)) + { + return Some(DmValue::Timestamp(d)); + } + } + None + } else if ty == DmValueType::DATE { + // Try 3-byte DM row format first (DATE_PREC = 3) + if data.len() >= 3 && data.len() < 7 { + let year = i32::from(i16::from_le_bytes([data[0], data[1]])) & 0x7FFF; + let month = ((data[1] as u32 >> 7) & 0x1) + ((data[2] as u32 & 0x07) << 1); + let day = ((data[2] as u32 & 0xF8) >> 3) & 0x1F; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + return Some(DmValue::Date(d)); + } + } + if data.len() >= 7 { + let year = u16::from_be_bytes([data[0], data[1]]) as i32; + let month = data[2] as u32; + let day = data[3] as u32; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + return Some(DmValue::Date(d)); + } + } + if data.len() >= 8 { + let year = i32::from(i16::from_le_bytes([data[0], data[1]])) & 0x7FFF; + let month = ((data[1] as u32 >> 7) & 0x1) + ((data[2] as u32 & 0x07) << 1); + let day = ((data[2] as u32 & 0xF8) >> 3) & 0x1F; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + return Some(DmValue::Date(d)); + } + } + None.map(DmValue::Date) + } else if ty == DmValueType::TIME && data.len() >= 6 { + let hour = data[0] as u32; + let minute = data[1] as u32; + let second = data[2] as u32; + let nano = if data.len() >= 10 { + u32::from_be_bytes([data[3], data[4], data[5], data[6]]) + } else { + 0 + }; + chrono::NaiveTime::from_hms_nano_opt(hour, minute, second, nano).map(DmValue::Time) + } else if ty == DmValueType::INTERVAL + || ty == DmValueType::INTERVAL_YM + || ty == DmValueType::INTERVAL_DT + { + Some(DmValue::Text(String::from_utf8_lossy(data).to_string())) + } else { + // Fallback for TIME_TZ, DATETIME_TZ, DATETIME2_TZ: text + Some(DmValue::Text(String::from_utf8_lossy(data).to_string())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dmvtype_from_type_code() { + assert_eq!(DmValueType::from_type_code(4), Some(DmValueType::INT)); + assert_eq!(DmValueType::from_type_code(3), Some(DmValueType::VARCHAR)); + assert_eq!(DmValueType::from_type_code(99), None); + } + + #[test] + fn test_dmvtype_type_code() { + assert_eq!(DmValueType::INT.type_code(), 4); + assert_eq!(DmValueType::BIGINT.type_code(), 5); + assert_eq!(DmValueType::BIT.type_code(), 1); + } + + #[test] + fn test_dmvtype_type_name() { + assert_eq!(DmValueType::INT.type_name(), "INT"); + assert_eq!(DmValueType::VARCHAR.type_name(), "VARCHAR"); + assert_eq!(DmValueType::TIMESTAMP.type_name(), "TIMESTAMP"); + } + + #[test] + fn test_encode_decode_int() { + let val = DmValue::Int(42); + let encoded = encode_value(DmValueType::INT, &val); + assert_eq!(encoded, vec![42, 0, 0, 0]); + let decoded = decode_value(DmValueType::INT, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_bigint() { + let val = DmValue::BigInt(1000); + let encoded = encode_value(DmValueType::BIGINT, &val); + assert_eq!(encoded, vec![0xE8, 0x03, 0, 0, 0, 0, 0, 0]); + let decoded = decode_value(DmValueType::BIGINT, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_text() { + let val = DmValue::Text("hello".to_string()); + let encoded = encode_value(DmValueType::VARCHAR, &val); + assert_eq!(encoded, b"hello"); + let decoded = decode_value(DmValueType::VARCHAR, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_bool() { + let val = DmValue::Boolean(true); + let encoded = encode_value(DmValueType::BIT, &val); + assert_eq!(encoded, vec![1]); + let decoded = decode_value(DmValueType::BIT, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_decode_empty() { + let result = decode_value(DmValueType::INT, &[], None); + assert_eq!(result, Some(DmValue::Null)); + } + + #[test] + fn test_encode_decode_bytea() { + let val = DmValue::Bytea(vec![0xDE, 0xAD, 0xBE, 0xEF]); + let encoded = encode_value(DmValueType::BLOB, &val); + assert_eq!(encoded, vec![0xDE, 0xAD, 0xBE, 0xEF]); + let decoded = decode_value(DmValueType::BLOB, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_new_type_codes() { + assert_eq!(DmValueType::NUMERIC.type_code(), 20); + assert_eq!(DmValueType::BOOLEAN.type_code(), 21); + assert_eq!(DmValueType::DATETIME.type_code(), 22); + assert_eq!(DmValueType::VARCHAR2.type_code(), 23); + assert_eq!(DmValueType::DATETIME2.type_code(), 24); + assert_eq!(DmValueType::TIME_TZ.type_code(), 25); + assert_eq!(DmValueType::DATETIME_TZ.type_code(), 26); + assert_eq!(DmValueType::INTERVAL_YM.type_code(), 27); + assert_eq!(DmValueType::INTERVAL_DT.type_code(), 28); + assert_eq!(DmValueType::RAW.type_code(), 29); + assert_eq!(DmValueType::DATETIME2_TZ.type_code(), 30); + assert_eq!(DmValueType::REAL.type_code(), 31); + } + + #[test] + fn test_new_from_type_code() { + assert_eq!(DmValueType::from_type_code(20), Some(DmValueType::NUMERIC)); + assert_eq!(DmValueType::from_type_code(21), Some(DmValueType::BOOLEAN)); + assert_eq!(DmValueType::from_type_code(22), Some(DmValueType::DATETIME)); + assert_eq!(DmValueType::from_type_code(23), Some(DmValueType::VARCHAR2)); + assert_eq!( + DmValueType::from_type_code(24), + Some(DmValueType::DATETIME2) + ); + assert_eq!(DmValueType::from_type_code(25), Some(DmValueType::TIME_TZ)); + assert_eq!( + DmValueType::from_type_code(26), + Some(DmValueType::DATETIME_TZ) + ); + assert_eq!( + DmValueType::from_type_code(27), + Some(DmValueType::INTERVAL_YM) + ); + assert_eq!( + DmValueType::from_type_code(28), + Some(DmValueType::INTERVAL_DT) + ); + assert_eq!(DmValueType::from_type_code(29), Some(DmValueType::RAW)); + assert_eq!( + DmValueType::from_type_code(30), + Some(DmValueType::DATETIME2_TZ) + ); + assert_eq!(DmValueType::from_type_code(31), Some(DmValueType::REAL)); + } + + #[test] + fn test_new_type_names() { + assert_eq!(DmValueType::NUMERIC.type_name(), "NUMERIC"); + assert_eq!(DmValueType::BOOLEAN.type_name(), "BOOLEAN"); + assert_eq!(DmValueType::DATETIME.type_name(), "DATETIME"); + assert_eq!(DmValueType::VARCHAR2.type_name(), "VARCHAR2"); + assert_eq!(DmValueType::DATETIME2.type_name(), "DATETIME2"); + assert_eq!(DmValueType::TIME_TZ.type_name(), "TIME_TZ"); + assert_eq!(DmValueType::DATETIME_TZ.type_name(), "DATETIME_TZ"); + assert_eq!(DmValueType::INTERVAL_YM.type_name(), "INTERVAL_YM"); + assert_eq!(DmValueType::INTERVAL_DT.type_name(), "INTERVAL_DT"); + assert_eq!(DmValueType::RAW.type_name(), "RAW"); + assert_eq!(DmValueType::DATETIME2_TZ.type_name(), "DATETIME2_TZ"); + assert_eq!(DmValueType::REAL.type_name(), "REAL"); + } + + #[test] + fn test_encode_decode_boolean() { + let val = DmValue::Boolean(false); + let encoded = encode_value(DmValueType::BOOLEAN, &val); + assert_eq!(encoded, vec![0]); + let decoded = decode_value(DmValueType::BOOLEAN, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_raw() { + let val = DmValue::Bytea(vec![0xAA, 0xBB]); + let encoded = encode_value(DmValueType::RAW, &val); + assert_eq!(encoded, vec![0xAA, 0xBB]); + let decoded = decode_value(DmValueType::RAW, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_real() { + let val = DmValue::Float(3.14f32); + let encoded = encode_value(DmValueType::REAL, &val); + assert_eq!(encoded, 3.14f32.to_le_bytes().to_vec()); + let decoded = decode_value(DmValueType::REAL, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_numeric() { + use rust_decimal::Decimal; + let val = DmValue::Decimal(Decimal::from(42)); + let encoded = encode_value(DmValueType::NUMERIC, &val); + assert_eq!(encoded, b"42"); + let decoded = decode_value(DmValueType::NUMERIC, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_varchar2() { + let val = DmValue::Text("test".to_string()); + let encoded = encode_value(DmValueType::VARCHAR2, &val); + assert_eq!(encoded, b"test"); + let decoded = decode_value(DmValueType::VARCHAR2, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } + + #[test] + fn test_encode_decode_datetime() { + let val = DmValue::Text("2024-01-01 12:00:00".to_string()); + let encoded = encode_value(DmValueType::DATETIME, &val); + assert_eq!(encoded, b"2024-01-01 12:00:00"); + let decoded = decode_value(DmValueType::DATETIME, &encoded, None).unwrap(); + assert_eq!(decoded, val); + } +} diff --git a/Native/DamengBridge/Vendor/dameng/Cargo.toml b/Native/DamengBridge/Vendor/dameng/Cargo.toml new file mode 100644 index 000000000..c830a2987 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dameng" +version = "0.1.0" +edition = "2021" +autoexamples = false +description = "Dameng database sync driver" +license = "MIT" +repository = "https://github.com/rarnu/rust-dameng" +keywords = ["dameng", "database", "driver"] +categories = ["database"] + +[dependencies] +dameng-protocol = { version = "0.1.0", path = "../dameng-protocol" } +dameng-types = { version = "0.1.0", path = "../dameng-types" } +rust_decimal = "1" +chrono = "0.4" +bytes = "1" +encoding_rs = "0.8" +native-tls = "0.2" diff --git a/Native/DamengBridge/Vendor/dameng/LICENSE.txt b/Native/DamengBridge/Vendor/dameng/LICENSE.txt new file mode 100644 index 000000000..81121d744 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 指令集 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Native/DamengBridge/Vendor/dameng/src/client.rs b/Native/DamengBridge/Vendor/dameng/src/client.rs new file mode 100644 index 000000000..ac4feb54f --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/src/client.rs @@ -0,0 +1,1655 @@ +//! Sync client for connecting to Dameng database. + +use native_tls::{TlsConnector, TlsStream as NativeTlsStream}; +use std::io::{Read, Write}; +use std::net::TcpStream; + +use bytes::{BufMut, BytesMut}; +use dameng_protocol::frame::{Frame, FRAME_HEADER_SIZE}; +use dameng_protocol::message::bind::BindParam; +use dameng_protocol::message::isolation::{IsolationLevel, SetIsolationMessage}; +use dameng_protocol::message::*; +use dameng_types::encoding::{decode_from_server, ServerEncoding}; + +/// Decode a server error message, trying UTF-8 first, then server encoding. +fn decode_error_msg(server_encoding: ServerEncoding, bytes: &[u8]) -> String { + if let Ok(s) = std::str::from_utf8(bytes) { + return s.to_string(); + } + decode_from_server(server_encoding, bytes) +} + +use crate::error::{Error, Result}; +use crate::row::ResultSet; + +const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024 * 1024; +const MAX_LOB_BYTES: usize = 64 * 1024 * 1024; + +fn checked_response_body_len(body_len: i32) -> Result { + let body_len = usize::try_from(body_len).map_err(|_| { + Error::Protocol(dameng_protocol::Error::InvalidFrame( + "negative response body length".to_string(), + )) + })?; + if body_len > MAX_RESPONSE_BODY_BYTES { + return Err(Error::Protocol(dameng_protocol::Error::InvalidFrame( + format!("response body length {body_len} exceeds {MAX_RESPONSE_BODY_BYTES} bytes"), + ))); + } + Ok(body_len) +} + +fn checked_lob_len(length: i64) -> Result { + let length = usize::try_from(length).map_err(|_| { + Error::Protocol(dameng_protocol::Error::InvalidFrame( + "negative LOB length".to_string(), + )) + })?; + if length > MAX_LOB_BYTES { + return Err(Error::Protocol(dameng_protocol::Error::InvalidFrame( + format!("LOB length {length} exceeds {MAX_LOB_BYTES} bytes"), + ))); + } + Ok(length) +} + +/// Convert a `ToDmValue` reference into a `BindParam` suitable for the DM protocol. +fn to_bind_param(value: &dyn dameng_types::ToDmValue) -> BindParam { + let dm_value = value.to_dm_value(); + match dm_value { + dameng_types::DmValue::Int(i) => BindParam { + type_name: "INT".to_string(), + type_code: 4, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(i.to_le_bytes().to_vec()), + }, + dameng_types::DmValue::BigInt(i) => BindParam { + type_name: "BIGINT".to_string(), + type_code: 5, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(i.to_le_bytes().to_vec()), + }, + dameng_types::DmValue::SmallInt(i) => BindParam { + type_name: "SMALLINT".to_string(), + type_code: 6, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(i.to_le_bytes().to_vec()), + }, + dameng_types::DmValue::TinyInt(i) => BindParam { + type_name: "TINYINT".to_string(), + type_code: 2, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(i.to_le_bytes().to_vec()), + }, + dameng_types::DmValue::Float(f) => BindParam { + type_name: "FLOAT".to_string(), + type_code: 7, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(f.to_le_bytes().to_vec()), + }, + dameng_types::DmValue::Double(d) => BindParam { + type_name: "DOUBLE".to_string(), + type_code: 8, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(d.to_le_bytes().to_vec()), + }, + dameng_types::DmValue::Text(s) => BindParam { + type_name: "VARCHAR".to_string(), + type_code: 3, + precision: s.len() as i32, + scale: 0, + direction: ParameterDirection::Input, + value: Some(s.into_bytes()), + }, + dameng_types::DmValue::Bytea(b) => BindParam { + type_name: "VARBINARY".to_string(), + type_code: 18, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(b), + }, + dameng_types::DmValue::Boolean(b) => BindParam { + type_name: "BIT".to_string(), + type_code: 1, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(vec![if b { 1 } else { 0 }]), + }, + dameng_types::DmValue::Null => BindParam { + type_name: "INT".to_string(), + type_code: 4, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: None, + }, + dameng_types::DmValue::Decimal(d) => BindParam { + type_name: "DECIMAL".to_string(), + type_code: 9, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(d.to_string().into_bytes()), + }, + dameng_types::DmValue::LobLocator(loc) => BindParam { + type_name: if loc.is_clob { + "CLOB".to_string() + } else { + "BLOB".to_string() + }, + type_code: if loc.is_clob { 14 } else { 13 }, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(loc.raw.to_vec()), + }, + dameng_types::DmValue::Date(d) => BindParam { + type_name: "DATE".to_string(), + type_code: 10, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(d.format("%Y-%m-%d").to_string().into_bytes()), + }, + dameng_types::DmValue::Time(t) => BindParam { + type_name: "TIME".to_string(), + type_code: 11, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(t.format("%H:%M:%S").to_string().into_bytes()), + }, + dameng_types::DmValue::Timestamp(ts) => BindParam { + type_name: "TIMESTAMP".to_string(), + type_code: 12, + precision: 0, + scale: 0, + direction: ParameterDirection::Input, + value: Some(ts.format("%Y-%m-%d %H:%M:%S").to_string().into_bytes()), + }, + } +} + +/// A stream that can be either plain TCP or TLS-wrapped. +enum Stream { + Tcp(TcpStream), + Tls(NativeTlsStream), +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Stream::Tcp(s) => s.read(buf), + Stream::Tls(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Stream::Tcp(s) => s.write(buf), + Stream::Tls(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.flush(), + Stream::Tls(s) => s.flush(), + } + } +} + +impl Stream { + fn shutdown(&mut self, how: std::net::Shutdown) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.shutdown(how), + Stream::Tls(s) => { + // For TLS, shutdown the underlying TCP stream + s.get_ref().shutdown(how) + } + } + } + + #[allow(unused)] + fn set_read_timeout(&mut self, dur: Option) -> std::io::Result<()> { + match self { + Stream::Tcp(s) => s.set_read_timeout(dur), + Stream::Tls(s) => s.get_ref().set_read_timeout(dur), + } + } +} + +/// Connection state. +#[derive(Debug, Clone, PartialEq)] +pub enum State { + Connected, + Authenticating, + Ready, + Closed, +} + +/// A synchronous Dameng database client. +pub struct Client { + stream: Option, + /// Connection state. + pub state: State, + /// Host. + pub host: String, + /// Port. + pub port: u16, + /// Connection handle. + pub handle: u32, + /// Server challenge for encryption. + pub challenge: Vec, + /// Auto-commit mode. + pub auto_commit: bool, + /// Transaction isolation level. + pub isolation_level: IsolationLevel, + /// Server encoding (1=UTF-8, 2=GB18030). + pub server_encoding: ServerEncoding, + /// Whether the server supports the extended LOB format (NewLobFlag). + pub new_lob_flag: bool, +} + +impl Client { + /// Create a new client for the given host and port. + pub fn new(host: &str, port: u16) -> Self { + Self { + stream: None, + state: State::Closed, + host: host.to_string(), + port, + handle: 0, + challenge: vec![], + auto_commit: true, + isolation_level: IsolationLevel::ReadCommitted, + server_encoding: ServerEncoding::Gb18030, + new_lob_flag: false, + } + } + + /// Connect to the Dameng server and complete authentication. + pub fn connect(&mut self, username: &str, password: &str) -> Result<()> { + self.connect_stream(false)?; + self.authenticate(username, password) + } + + /// Connect with SSL/TLS. + pub fn connect_ssl(&mut self, username: &str, password: &str) -> Result<()> { + self.connect_stream(true)?; + self.authenticate(username, password) + } + + /// Establish the underlying TCP or TLS stream. + fn connect_stream(&mut self, use_ssl: bool) -> Result<()> { + let addr = format!("{}:{}", self.host, self.port); + let stream = TcpStream::connect(&addr)?; + stream.set_read_timeout(Some(std::time::Duration::from_secs(10)))?; + stream.set_write_timeout(Some(std::time::Duration::from_secs(10)))?; + + if use_ssl { + let connector = TlsConnector::new() + .map_err(|e| Error::ConnectionFailed(format!("TLS init failed: {}", e)))?; + let tls_stream = connector + .connect(&self.host, stream) + .map_err(|e| Error::ConnectionFailed(format!("TLS handshake failed: {}", e)))?; + self.stream = Some(Stream::Tls(tls_stream)); + } else { + self.stream = Some(Stream::Tcp(stream)); + } + Ok(()) + } + + /// Complete the authentication handshake after stream is established. + fn authenticate(&mut self, username: &str, password: &str) -> Result<()> { + self.send_startup()?; + let resp = self.read_startup_response()?; + self.challenge = resp.challenge.to_vec(); + self.state = State::Authenticating; + + self.send_login(username, password)?; + let login_resp = self.read_login_response()?; + // Save server encoding from LOGIN_RESPONSE (1=UTF-8, 2=GB18030) + self.server_encoding = ServerEncoding::from_protocol_value(login_resp.encoding); + // Save connection handle (session_id) for subsequent protocol messages + self.handle = login_resp.session_id; + if !login_resp.username.is_empty() { + self.state = State::Ready; + Ok(()) + } else { + Err(Error::AuthFailed(format!("login failed for {}", username))) + } + } + + /// Connect using a ConnectOptions configuration struct. + /// + /// Convenience method that creates a Client from ConnectOptions + /// and connects to the server in one call. + pub fn connect_with(opts: &crate::config::ConnectOptions) -> Result { + let mut client = Self::new(&opts.host, opts.port); + client.auto_commit = opts.auto_commit; + client.isolation_level = opts.isolation_level; + + if let Some(_timeout) = opts.connect_timeout { + // Apply timeout when creating the TCP stream + // (applied inside connect() via custom stream creation) + } + + if opts.ssl { + client.connect_ssl(&opts.username, &opts.password)?; + } else { + client.connect(&opts.username, &opts.password)?; + } + Ok(client) + } + + /// Connect using a DSN string. + /// + /// DSN format: `dm://username:password@host:port/schema?param1=value1¶m2=value2` + /// + /// Supported query parameters: + /// - `charset`: Character set (e.g., "utf8", "gb18030") + /// - `schema`: Database schema + /// - `timezone`: Timezone offset in hours + /// - `ssl`: Enable SSL ("true" or "false") + /// - `max_row_size`: Maximum row size + /// - `connect_timeout`: Connection timeout in seconds + /// - `auto_commit`: Auto-commit mode ("true" or "false") + /// - `isolation_level`: "read_uncommitted", "read_committed", "repeatable_read", "serializable" + /// + /// # Example + /// + /// ```ignore + /// let client = Client::connect_from_dsn( + /// "dm://SYSDBA:SYSDBA@127.0.0.1:5236/?charset=utf8&auto_commit=true" + /// ).unwrap(); + /// ``` + pub fn connect_from_dsn(dsn: &str) -> Result { + let opts = crate::config::ConnectOptions::from_dsn(dsn)?; + Self::connect_with(&opts) + } + + /// Send a startup message to the server. + fn send_startup(&mut self) -> Result<()> { + let msg = StartupMessage::new(); + let payload = msg.encode_payload(); + let frame_data = build_message(STARTUP, 0, &payload); + self.write_all(&frame_data)?; + Ok(()) + } + + /// Read the server's startup response. + fn read_startup_response(&mut self) -> Result { + let (frame, payload) = self.read_message()?; + if frame.msg_type != STARTUP_RESPONSE && frame.msg_type != ACK { + return Err(Error::ConnectionFailed(format!( + "expected STARTUP_RESPONSE or ACK got msg_type={}", + frame.msg_type + ))); + } + StartupResponse::from_bytes(&payload, frame.response_code).map_err(|e| Error::Protocol(e)) + } + + /// Send login credentials to the server. + fn send_login(&mut self, username: &str, password: &str) -> Result<()> { + let login = LoginMessage::new(username, password, &self.host); + let payload = login.encode_payload(&self.challenge); + let frame_data = build_message(LOGIN, 0, &payload); + self.write_all(&frame_data)?; + Ok(()) + } + + /// Read the login response. + fn read_login_response(&mut self) -> Result { + let (frame, payload) = self.read_message()?; + // Some DM servers respond with ACK(187) instead of LOGIN_RESPONSE(163). + if frame.msg_type != LOGIN_RESPONSE && frame.msg_type != ACK { + return Err(Error::ConnectionFailed(format!( + "expected LOGIN_RESPONSE got msg_type={}", + frame.msg_type + ))); + } + // ACK responses have short payloads — LoginResponse::from_bytes needs >= 0x50 bytes. + // Fall back to a minimal response built from the frame. + LoginResponse::from_bytes(&payload).or_else(|_| { + Ok(LoginResponse { + session_id: frame.handle as u32, + encoding: 1, + server_status: 0, + server_name: String::new(), + username: String::new(), + client_ip: String::new(), + login_datetime: String::new(), + db_name: String::new(), + }) + }) + } + + /// Begin a new transaction by first committing any pending changes, + /// then disabling auto-commit on the client side. + /// DM server manages transactions implicitly - all operations from connection + /// start are in one transaction until COMMIT/ROLLBACK is sent. + pub fn begin(&mut self) -> Result<()> { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + // Commit any pending changes before starting a new transaction + if self.auto_commit { + self.do_commit()?; + } + self.auto_commit = false; + Ok(()) + } + + /// Allocate a new statement handle from the server. + pub fn allocate_statement(&mut self) -> Result { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + let alloc = StatementAllocateMessage::new(); + let payload = alloc.encode_payload(); + self.write_all(&build_message(STATEMENT_PREPARE, self.handle, &payload))?; + let (frame, resp_payload) = self.read_message()?; + if frame.response_code < 0 { + return Err(Error::ConnectionFailed(format!( + "allocate statement failed: code={}", + frame.response_code + ))); + } + let stmt_id = StatementAllocateMessage::parse_response(&resp_payload) + .map_err(|e| Error::Protocol(e))?; + Ok(stmt_id) + } + + /// Free a statement handle. + pub fn free_statement(&mut self, stmt_id: u32) -> Result<()> { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + let free = StatementFreeMessage::new(stmt_id); + let payload = free.encode_payload(); + self.write_all(&build_message(STATEMENT_FREE, 0, &payload))?; + let (frame, _) = self.read_message()?; + if frame.response_code < 0 { + return Err(Error::ConnectionFailed(format!( + "free statement {} failed: code={}", + stmt_id, frame.response_code + ))); + } + Ok(()) + } + + /// Prepare a SQL statement on the server. + pub fn prepare(&mut self, stmt_id: u32, sql: &str) -> Result<()> { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + let ready_frame = Frame::new(READY, 0, 0); + self.write_all(&ready_frame.encode())?; + self.read_message()?; + + let exec = ExecMessage::new(sql, 0); + let exec_payload = exec.encode_payload(); + self.write_all(&build_message(EXEC, stmt_id, &exec_payload))?; + let (frame, _) = self.read_message()?; + if frame.response_code < 0 { + return Err(Error::QueryFailed(format!( + "prepare failed: code={}", + frame.response_code + ))); + } + Ok(()) + } + + /// Execute a SQL statement with dynamic parameters and return the number of affected rows. + /// + /// For DML: INSERT, UPDATE, DELETE, CREATE, DROP, etc. + /// Auto-commits if `auto_commit` is enabled. + /// + /// # SQLx-style usage + /// + /// ```ignore + /// let name = "Alice"; + /// let data = b"payload"; + /// let affected = client.execute_with_params( + /// "INSERT INTO person (name, data) VALUES (?, ?)", + /// &[&name, &data], + /// )?; + /// ``` + /// + /// Supported parameter types via `ToDmValue`: + /// - `&i8`, `&i16`, `&i32`, `&i64`, `&f32`, `&f64`, `&bool` + /// - `&str`, `&String`, `&[u8]`, `&Vec` + /// - `&Option` for all above (sends NULL when `None`) + pub fn execute_with_params( + &mut self, + sql: &str, + params: &[&dyn dameng_types::ToDmValue], + ) -> Result { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + + let bind_params: Vec = params.iter().map(|p| to_bind_param(*p)).collect(); + let rs = self.do_prepare_execute(&bind_params, sql, false)?; + Ok(rs.total_row_count) + } + + /// Execute a SQL SELECT query with dynamic parameters and return the result set. + /// + /// For SELECT statements only. Does NOT auto-commit. + /// + /// # SQLx-style usage + /// + /// ```ignore + /// let id: i32 = 1; + /// let age: i32 = 18; + /// let rows = client.query_with_params( + /// "SELECT * FROM person WHERE id > ? AND age > ?", + /// &[&id, &age], + /// )?; + /// ``` + pub fn query_with_params( + &mut self, + sql: &str, + params: &[&dyn dameng_types::ToDmValue], + ) -> Result { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + + let bind_params: Vec = params.iter().map(|p| to_bind_param(*p)).collect(); + self.do_prepare_execute(&bind_params, sql, true) + } + + /// Internal: execute SQL with pre-built BindParams (shared by sqlx/query builder modules). + #[allow(unused)] + pub(crate) fn do_execute_with_bind_params( + &mut self, + sql: &str, + has_result_set: bool, + params: &[BindParam], + ) -> Result { + self.do_prepare_execute(params, sql, has_result_set) + } + + /// Substitute ? placeholders with SQL literal values. + fn substitute_params(sql: &str, params: &[BindParam]) -> String { + let mut result = String::with_capacity(sql.len() + params.len() * 16); + let mut pi = 0; + for b in sql.bytes() { + if b == b'?' && pi < params.len() { + let lit = Self::bind_param_literal(¶ms[pi]); + result.push_str(&lit); + pi += 1; + } else { + result.push(b as char); + } + } + result + } + + /// Convert a BindParam to a SQL literal string. + fn bind_param_literal(p: &BindParam) -> String { + match &p.value { + None => "NULL".to_string(), + Some(v) => match p.type_code { + // BIT + 1 => format!("{}", v.first().copied().unwrap_or(0)), + // VARCHAR, CLOB — quoted (match before numeric range 2..=6) + 3 | 14 => { + let s = String::from_utf8_lossy(v); + format!("'{}'", s.replace('\'', "''")) + } + // Numeric: TINYINT(2)/SMALLINT(6)/INT(4)/BIGINT(5) + 2 | 6 | 4 | 5 => { + if v.len() <= 8 { + let mut buf = [0u8; 8]; + buf[..v.len()].copy_from_slice(v); + match v.len() { + 1 => format!("{}", buf[0] as i8), + 2 => format!("{}", i16::from_le_bytes([buf[0], buf[1]])), + 4 => { + format!("{}", i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])) + } + 8 => format!("{}", i64::from_le_bytes(buf)), + _ => String::from_utf8_lossy(v).to_string(), + } + } else { + String::from_utf8_lossy(v).to_string() + } + } + // FLOAT(7)/DOUBLE(8) + 7 | 8 => { + if v.len() == 4 { + format!("{}", f32::from_le_bytes([v[0], v[1], v[2], v[3]])) + } else if v.len() == 8 { + format!( + "{}", + f64::from_le_bytes([v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]]) + ) + } else { + String::from_utf8_lossy(v).to_string() + } + } + // DECIMAL (sent as string) + 9 => String::from_utf8_lossy(v).to_string(), + // DATE, TIME, TIMESTAMP — quoted string + 10 | 11 | 12 => { + let s = String::from_utf8_lossy(v); + format!("'{}'", s.replace('\'', "''")) + } + // BLOB, VARBINARY — hex + 13 | 18 => { + let h: String = v.iter().map(|b| format!("{:02x}", b)).collect(); + format!("0x{}", h) + } + _ => { + let h: String = v.iter().map(|b| format!("{:02x}", b)).collect(); + format!("0x{}", h) + } + }, + } + } + + /// Core execution: READY → OPTIMIZED_PREPARE_EXEC (OPE(91)) for no params, + /// or text substitution + OPE(91) for params. + /// + /// All SQL execution flows through this single method. The `params` slice + /// may be empty (no parameters) or contain bound parameters. The `has_result_set` + /// flag controls whether the server returns rows (SELECT) or affected count (DML). + fn do_prepare_execute( + &mut self, + params: &[BindParam], + sql: &str, + has_result_set: bool, + ) -> Result { + // No params: use OPE(91) fast path — single message, prepare + execute. + if params.is_empty() { + let ready_frame = Frame::new(READY, 0, 0); + self.write_all(&ready_frame.encode())?; + self.read_message()?; + + let exec = ExecMessage::new(sql, 0); + self.write_all(&build_message( + OPTIMIZED_PREPARE_EXEC, + 0, + &exec.encode_payload(), + ))?; + + let rs = match self.read_exec_response(has_result_set) { + Ok(r) => r, + Err(e) => { + // On DML error, send ROLLBACK to clean up connection state + if !has_result_set { + let _ = self.rollback(); + } + return Err(e); + } + }; + + if self.auto_commit && !has_result_set { + self.do_commit()?; + } + return Ok(rs); + } + + // With params: use text substitution + OPE(91) no-params path. + // DM 8.1.3.62 does NOT support OPE(91) with embedded params or BIND_EXEC2 + // inline data — substitute values into SQL text and use the OPE(91) no-params path. + let substituted = Self::substitute_params(sql, params); + self.write_all(&Frame::new(READY, 0, 0).encode())?; + self.read_message()?; + let exec = ExecMessage::new(&substituted, 0); + self.write_all(&build_message( + OPTIMIZED_PREPARE_EXEC, + 0, + &exec.encode_payload(), + ))?; + let rs = self.read_exec_response(has_result_set)?; + if self.auto_commit && !has_result_set { + self.do_commit()?; + } + return Ok(rs); + } + + /// Stream LOB data for off-row params (BLOB/CLOB > 2048 bytes). + #[allow(unused)] + fn stream_lob_params(&mut self, stmt_id: u32, params: &[BindParam]) -> Result<()> { + let off_row_params: Vec = params + .iter() + .enumerate() + .filter(|(_, p)| { + let is_lob = p.type_code == 13 || p.type_code == 14; + is_lob && p.value.as_ref().map_or(false, |v| v.len() > 2048) + }) + .map(|(i, _)| i) + .collect(); + + for ¶m_idx in &off_row_params { + let param = ¶ms[param_idx]; + if let Some(ref data) = param.value { + for chunk in &split_lob_data(data) { + let lob_msg = LobDataMessage::new(param_idx as i16, chunk.clone()); + let lob_payload = lob_msg.encode_payload(self.new_lob_flag); + self.write_all(&build_message(DM_LOB_DATA_MSG_TYPE, stmt_id, &lob_payload))?; + let (lob_frame, _) = self.read_message()?; + if lob_frame.response_code < 0 { + return Err(Error::QueryFailed(format!( + "LOB stream failed for param {}: code={}", + param_idx, lob_frame.response_code + ))); + } + } + } + } + + Ok(()) + } + + /// Clone params, clearing value for off-row LOB placeholders. + #[allow(unused)] + fn clear_off_row_placeholders(&self, params: &[BindParam]) -> Vec { + let off_row_params: Vec = params + .iter() + .enumerate() + .filter(|(_, p)| { + let is_lob = p.type_code == 13 || p.type_code == 14; + is_lob && p.value.as_ref().map_or(false, |v| v.len() > 2048) + }) + .map(|(i, _)| i) + .collect(); + + params + .iter() + .enumerate() + .map(|(i, p)| { + if off_row_params.contains(&i) { + let mut modified = p.clone(); + modified.value = Some(vec![]); + modified + } else { + p.clone() + } + }) + .collect() + } + + /// Fetch all rows from a BIND_EXEC2 result using FETCH protocol. + /// + /// When BIND_EXEC2 returns col_count=0 but total_row_count > 0, + /// the data must be retrieved via FETCH messages. + #[allow(unused)] + fn fetch_from_bind_exec(&mut self, stmt_id: u32, total_rows: u64) -> Result { + let mut all_columns = Vec::new(); + let mut all_rows = Vec::new(); + + let mut start_row: i64 = 0; + let prefetch = 65536i32; + + loop { + let fetch = FetchMessage::new(start_row, 0, prefetch); + let fetch_payload = fetch.encode_payload(); + // Use connection handle (self.handle), not stmt_id, matching fetch_more() + self.write_all(&build_message(FETCH, self.handle, &fetch_payload))?; + + let (frame, payload) = self.read_message()?; + if frame.response_code < 0 { + let msg = String::from_utf8_lossy(&payload); + return Err(Error::QueryFailed(format!( + "fetch failed: code={} type={} payload={}", + frame.response_code, frame.msg_type, msg + ))); + } + + let fetch_resp = FetchResponse::from_bytes(&payload, self.server_encoding) + .map_err(|e| Error::Protocol(e))?; + + // Collect columns from first fetch response + if all_columns.is_empty() && !fetch_resp.columns.is_empty() { + all_columns = fetch_resp.columns; + } + + let fetched_rows = fetch_resp.rows; + let fetched_count = fetched_rows.len(); + all_rows.extend(fetched_rows); + start_row += fetched_count as i64; + + if start_row >= fetch_resp.total_row_count as i64 || fetched_count == 0 { + break; + } + } + + Ok(ResultSet::with_data(all_columns, all_rows, 0, total_rows)) + } + + /// Set transaction isolation level. + /// + /// Sends a SET_ISOLATION (type 52) message to the DM server. + /// Supported levels: ReadUncommitted, ReadCommitted, RepeatableRead, Serializable. + pub fn set_isolation(&mut self, level: IsolationLevel) -> Result<()> { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + let msg = SetIsolationMessage::new(level); + let frame = msg.encode_frame(self.handle); + self.write_all(&frame)?; + let (frame, payload) = self.read_message()?; + if frame.response_code < 0 { + let msg = decode_error_msg(self.server_encoding, &payload); + return Err(Error::QueryFailed(format!( + "set isolation failed: code={} type={} payload={}", + frame.response_code, frame.msg_type, msg + ))); + } + self.isolation_level = level; + Ok(()) + } + + /// Get current transaction isolation level. + pub fn get_isolation_level(&self) -> IsolationLevel { + self.isolation_level + } + + /// Execute a SQL statement and return the number of affected rows. + /// Use for DML: INSERT, UPDATE, DELETE, CREATE, DROP, COMMIT, ROLLBACK. + /// When auto_commit is true (default), a COMMIT is sent after each statement. + pub fn execute(&mut self, sql: &str) -> Result { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + let rs = self.do_prepare_execute(&[], sql, false)?; + Ok(rs.total_row_count) + } + + /// Internal commit - sends the COMMIT protocol message. + fn do_commit(&mut self) -> Result<()> { + let commit = CommitMessage; + let payload = commit.encode_payload(); + self.write_all(&build_message(COMMIT, self.handle, &payload))?; + let (frame, _payload) = self.read_message()?; + if frame.msg_type != ACK && frame.msg_type != EXEC_RESPONSE { + return Err(Error::ConnectionFailed(format!( + "expected ACK/EXEC_RESPONSE for COMMIT got msg_type={}", + frame.msg_type + ))); + } + if frame.response_code < 0 { + return Err(Error::ConnectionFailed(format!( + "COMMIT failed with resp_code={}", + frame.response_code + ))); + } + Ok(()) + } + + /// Commit with affected rows - sends COMMIT and reads queued EXEC_RESPONSE first. + /// After OPE(91) DML, DM queues the EXEC_RESPONSE and sends it when we issue the + #[allow(unused)] + fn do_commit_with_affected(&mut self) -> Result { + // Send READY to trigger the server to flush queued EXEC_RESPONSE + let ready_frame = Frame::new(READY, 0, 0); + self.write_all(&ready_frame.encode())?; + + // Read ALL messages until we find an EXEC_RESPONSE or get nothing + let mut affected = 0u64; + loop { + match self.try_read_message(std::time::Duration::from_millis(200)) { + Some(Ok((frame, payload))) => { + if frame.response_code < 0 { + return Err(Error::QueryFailed(format!( + "response_code={}", + frame.response_code + ))); + } + + // EXEC_RESPONSE(0) or type 160 contains the actual result data + if frame.msg_type == EXEC_RESPONSE || frame.msg_type == 160 { + if payload.len() >= 16 { + // offset 12 = row_count in EXEC_RESPONSE payload + affected = u32::from_le_bytes([ + payload[12], + payload[13], + payload[14], + payload[15], + ]) as u64; + } + break; + } + + // ACK(187) with data might also contain result + if frame.msg_type == ACK && payload.len() >= 16 { + // Check if this looks like EXEC_RESPONSE data + affected = u32::from_le_bytes([ + payload[12], + payload[13], + payload[14], + payload[15], + ]) as u64; + } + // Empty ACK means we're done + if frame.msg_type == ACK && payload.is_empty() { + break; + } + } + Some(Err(e)) => return Err(e), + None => break, + } + } + + // Now send actual COMMIT + let commit = CommitMessage; + let payload = commit.encode_payload(); + self.write_all(&build_message(COMMIT, self.handle, &payload))?; + + // Read COMMIT response + let (frame2, _p2) = self.read_message()?; + if frame2.response_code < 0 { + return Err(Error::ConnectionFailed(format!( + "COMMIT failed with resp_code={}", + frame2.response_code + ))); + } + + Ok(affected) + } + + /// Read an EXEC_RESPONSE and parse into Rows. + /// + /// For OPE(91) the server may send a sequence of messages: + /// ACK(187) with data → ACK(187) empty → EXEC_RESPONSE(0) with data + /// We consume all of them and extract affected row count / result data. + /// + /// `has_result_set` indicates whether this is a SELECT query (true) or DML (false). + /// For SELECT queries via OPE(91), the server returns a single ACK with inline + /// data — no trailing messages to consume. + /// For DML via OPE(91), the server returns an empty ACK (update_count in header) + /// with no trailing messages either. + /// For BIND_EXEC2 SELECT queries, the server returns col_count=0 and we should + /// not try to parse inline data — it must be fetched via FETCH. + fn read_exec_response(&mut self, has_result_set: bool) -> Result { + let (frame, payload) = self.read_message()?; + + // Check for error response (negative response_code) + if frame.response_code < 0 { + let mut error_detail = format!("response_code={}", frame.response_code); + if payload.len() >= 16 { + let msg_len = u32::from_le_bytes([ + payload[12], + payload.get(13).copied().unwrap_or(0), + payload.get(14).copied().unwrap_or(0), + payload.get(15).copied().unwrap_or(0), + ]) as usize; + if msg_len > 0 && payload.len() >= 16 + msg_len { + let msg = decode_error_msg(self.server_encoding, &payload[16..16 + msg_len]); + error_detail = format!("{}: {}", frame.response_code, msg); + } + } + return Err(Error::QueryFailed(error_detail)); + } + + if frame.msg_type == EXPLAIN_RESPONSE { + let response = ExplainResponse::from_bytes(&payload, self.server_encoding)?; + let display_size = u32::try_from(response.plan.len()).unwrap_or(u32::MAX); + let columns = vec![Column { + name: "PLAN".to_string(), + type_code: dm_type::VARCHAR, + type_name: "VARCHAR".to_string(), + precision: display_size, + scale: 0, + nullable: false, + display_size, + table_name: String::new(), + schema_name: String::new(), + lob_tab_id: 0, + lob_col_id: 0, + }]; + let rows = vec![Row { + row_id: 0, + values: vec![Some(response.plan.into_bytes())], + }]; + return Ok(ResultSet::with_data(columns, rows, 0, 1)); + } + + if frame.msg_type == ACK && payload.is_empty() { + // OPE(91) DML: empty ACK with affected rows in header reserved area at offset 24. + let affected = frame.update_count; + return Ok(ResultSet::with_data(Vec::new(), Vec::new(), 0, affected)); + } + + if frame.msg_type == ACK { + // OPE(91) SELECT: ACK with inline row data in payload. + // For SELECT queries this is the complete response — no trailing messages. + // For DML queries there may be trailing messages, handled after parsing. + let resp = ExecResponse::from_bytes(&payload, self.server_encoding)?; + // OPE offset 12 describes result metadata, not the number of rows. It + // remains nonzero for an empty SELECT, which would otherwise trigger + // an invalid FETCH against a response that is already complete. + let mut total = resp.rows.len() as u64; + if !has_result_set { + // DML with trailing messages (rare path) + let trailing = self.consume_remaining_ope_messages()?; + if total == 0 { + total = trailing; + } + } + return Ok(ResultSet::with_data(resp.columns, resp.rows, 0, total)); + } + + if frame.msg_type == EXEC_RESPONSE || frame.msg_type == 160 { + let resp = ExecResponse::from_bytes(&payload, self.server_encoding)?; + + // For BIND_EXEC2 SELECT queries: server returns col_count=0. + // Do NOT parse inline data (it's garbage) — let the caller use FETCH. + if has_result_set && resp.col_count == 0 && !resp.columns.is_empty() { + // This shouldn't happen if guard is correct, but be safe. + // Fall through to normal path. + } else if has_result_set && resp.col_count == 0 && resp.columns.is_empty() { + // BIND_EXEC2 SELECT: no inline data, will be fetched via FETCH. + return Ok(ResultSet::with_data( + Vec::new(), + Vec::new(), + 0, + resp.row_count as u64, + )); + } + + return Ok(ResultSet::with_data( + resp.columns, + resp.rows, + 0, + resp.row_count as u64, + )); + } + + Err(Error::ConnectionFailed(format!( + "unexpected response msg_type={}", + frame.msg_type + ))) + } + + /// Consume remaining messages after an OPE(91) response. + /// The server may send trailing ACK(empty) and/or EXEC_RESPONSE messages + /// that need to be consumed to keep the connection in sync. + /// Returns the affected row count from the frame header if found. + fn consume_remaining_ope_messages(&mut self) -> Result { + let mut affected = 0u64; + + // Try to read one more message with a short timeout + if let Some(Ok((frame, payload))) = + self.try_read_message(std::time::Duration::from_millis(2000)) + { + if frame.response_code < 0 { + return Err(Error::QueryFailed(format!( + "response_code={}", + frame.response_code + ))); + } + // If we got an empty ACK, try one more (EXEC_RESPONSE with affected rows in frame header) + if frame.msg_type == ACK && payload.is_empty() { + if let Some(Ok((f3, _p3))) = + self.try_read_message(std::time::Duration::from_millis(2000)) + { + if f3.response_code < 0 { + return Err(Error::QueryFailed(format!( + "response_code={}", + f3.response_code + ))); + } + // Affected rows in frame header offset 14-17 + affected = f3.affected_rows as u64; + } + } + // Also check if this message itself has affected rows in frame header + if frame.msg_type == EXEC_RESPONSE { + affected = frame.affected_rows as u64; + } + } + + Ok(affected) + } + + /// Try to read a single message with polling. + /// The `timeout` is the maximum time to wait for data. + /// Returns None if no message arrives within the timeout. + /// Some(Ok(...)) on success, Some(Err(...)) on error. + fn try_read_message( + &mut self, + timeout: std::time::Duration, + ) -> Option)>> { + use std::io::ErrorKind; + + let stream = self.stream.as_mut()?; + let deadline = std::time::Instant::now() + timeout; + let mut buf = BytesMut::with_capacity(FRAME_HEADER_SIZE + 4096); + + // Read frame header with polling + loop { + if buf.len() >= FRAME_HEADER_SIZE { + break; + } + if std::time::Instant::now() > deadline { + return None; + } + let needed = FRAME_HEADER_SIZE - buf.len(); + let mut tmp = vec![0u8; needed]; + match stream.read(&mut tmp) { + Ok(0) => return None, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + Err(e) if e.kind() == ErrorKind::WouldBlock || e.raw_os_error() == Some(35) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => return Some(Err(Error::Io(e))), + } + } + + let frame = match Frame::parse(&mut buf) { + Ok(f) => f, + Err(_) => return None, + }; + + // Read payload with polling + let body_len = match checked_response_body_len(frame.body_len) { + Ok(length) => length, + Err(error) => return Some(Err(error)), + }; + while buf.len() < body_len { + if std::time::Instant::now() > deadline { + return None; + } + let needed = body_len - buf.len(); + let mut tmp = vec![0u8; needed.min(4096)]; + match stream.read(&mut tmp) { + Ok(0) => { + return Some(Err(Error::ConnectionFailed( + "connection closed during payload read".to_string(), + ))) + } + Ok(n) => buf.extend_from_slice(&tmp[..n]), + Err(e) if e.kind() == ErrorKind::WouldBlock || e.raw_os_error() == Some(35) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => return Some(Err(Error::Io(e))), + } + } + + Some(Ok((frame, buf[..body_len].to_vec()))) + } + + /// Execute a SQL SELECT query and return the result set. + /// + /// Does NOT auto-commit (SELECT queries should not trigger commits). + pub fn query(&mut self, sql: &str) -> Result { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + self.do_prepare_execute(&[], sql, true) + } + + /// Fetch more rows from a result set using the FETCH protocol (msg_type=7). + /// + /// # Arguments + /// * `result_set` - The ResultSet from the initial query (will be mutated) + /// * `start_row` - The absolute row index to fetch from (0-based) + /// * `prefetch_bytes` - Maximum bytes to fetch (clamped to [32, 65536]) + /// + /// # Returns + /// The total row count in the result set (from the server). + /// + /// # Example + /// ```ignore + /// let mut rs = client.query("SELECT * FROM large_table")?; + /// let batch_size = 100; + /// while rs.rows.len() < rs.total_row_count as usize { + /// let fetched = client.fetch_more(&mut rs, rs.rows.len(), 8192)?; + /// // Process new rows from rs.rows[previous_len..] + /// } + /// ``` + pub fn fetch_more( + &mut self, + result_set: &mut ResultSet, + start_row: usize, + prefetch_bytes: i32, + ) -> Result { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + + // Send FETCH message (msg_type=7) + let fetch = FetchMessage::new(start_row as i64, result_set.cursor_id, prefetch_bytes); + let fetch_payload = fetch.encode_payload(); + self.write_all(&build_message(FETCH, self.handle, &fetch_payload))?; + + let (frame, payload) = self.read_message()?; + if frame.response_code < 0 { + let msg = String::from_utf8_lossy(&payload); + return Err(Error::QueryFailed(format!( + "fetch failed: code={} type={} payload={}", + frame.response_code, frame.msg_type, msg + ))); + } + + // Parse FETCH response + let fetch_resp = FetchResponse::from_bytes(&payload, self.server_encoding) + .map_err(|e| Error::Protocol(e))?; + + // Append new rows to the result set + result_set.rows.extend(fetch_resp.rows); + + // Update total row count from server response + result_set.total_row_count = fetch_resp.total_row_count as u64; + + // Merge columns if fetch response includes column metadata + if result_set.columns.is_empty() && !fetch_resp.columns.is_empty() { + result_set.columns = fetch_resp.columns; + } + + Ok(result_set.total_row_count) + } + + /// Send a READY keepalive and read the ACK. + pub fn ready(&mut self) -> Result<()> { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + let ready = ReadyMessage::new(); + let payload = ready.encode_payload(); + self.write_all(&build_message(READY, self.handle, &payload))?; + let (frame, _) = self.read_message()?; + if frame.msg_type != ACK { + return Err(Error::ConnectionFailed(format!( + "expected ACK for READY got msg_type={}", + frame.msg_type + ))); + } + Ok(()) + } + + /// Commit the current transaction and re-enable auto-commit. + pub fn commit(&mut self) -> Result<()> { + self.do_commit()?; + self.auto_commit = true; + // COMMIT may also invalidate the server-side statement handle. + // Reset to 0 so the next execute() will allocate a fresh one. + self.handle = 0; + Ok(()) + } + + /// Rollback the current transaction and re-enable auto-commit. + pub fn rollback(&mut self) -> Result<()> { + let rollback = RollbackMessage; + let payload = rollback.encode_payload(); + self.write_all(&build_message(ROLLBACK, self.handle, &payload))?; + let (frame, _) = self.read_message()?; + if frame.msg_type != ACK && frame.msg_type != EXEC_RESPONSE { + return Err(Error::ConnectionFailed(format!( + "expected ACK/EXEC_RESPONSE for ROLLBACK got msg_type={}", + frame.msg_type + ))); + } + if frame.response_code < 0 { + return Err(Error::ConnectionFailed(format!( + "ROLLBACK failed with resp_code={}", + frame.response_code + ))); + } + self.auto_commit = true; + // ROLLBACK invalidates the server-side statement handle (-2106). + // Reset to 0 so the next execute() will allocate a fresh one. + self.handle = 0; + Ok(()) + } + + /// Read a complete message (frame + payload) from the stream. + /// Reads exactly one frame at a time — never over-reads past the current + /// frame boundary, because over-read data would be silently dropped. + fn read_message(&mut self) -> Result<(Frame, Vec)> { + use std::io::ErrorKind; + + let stream = self.stream.as_mut().ok_or(Error::NotConnected)?; + let mut buf = BytesMut::with_capacity(FRAME_HEADER_SIZE + 4096); + + // Read header using BytesMut chunk for zero-copy + while buf.len() < FRAME_HEADER_SIZE { + let needed = FRAME_HEADER_SIZE - buf.len(); + buf.reserve(needed); + let chunk = buf.chunk_mut(); + // SAFETY: we will write to these bytes, making them initialized + let dst = unsafe { std::slice::from_raw_parts_mut(chunk.as_mut_ptr(), chunk.len()) }; + let cap = dst.len().min(needed); + let n = loop { + match stream.read(&mut dst[..cap]) { + Ok(n) => break n, + Err(e) if e.kind() == ErrorKind::WouldBlock || e.raw_os_error() == Some(35) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + continue; + } + Err(e) => return Err(Error::Io(e)), + } + }; + if n == 0 { + return Err(Error::ConnectionFailed("connection closed".to_string())); + } + // SAFETY: we just read n bytes into the chunk + unsafe { + buf.advance_mut(n); + } + } + + let frame = Frame::parse(&mut buf)?; + + // Read payload using same zero-copy approach + let body_len = checked_response_body_len(frame.body_len)?; + while buf.len() < body_len { + let needed = body_len - buf.len(); + buf.reserve(needed); + let chunk = buf.chunk_mut(); + // SAFETY: we will write to these bytes, making them initialized + let dst = unsafe { std::slice::from_raw_parts_mut(chunk.as_mut_ptr(), chunk.len()) }; + let cap = dst.len().min(needed); + let n = loop { + match stream.read(&mut dst[..cap]) { + Ok(n) => break n, + Err(e) if e.kind() == ErrorKind::WouldBlock || e.raw_os_error() == Some(35) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + continue; + } + Err(e) => return Err(Error::Io(e)), + } + }; + if n == 0 { + return Err(Error::ConnectionFailed( + "connection closed during payload read".to_string(), + )); + } + unsafe { + buf.advance_mut(n); + } + } + + let payload = buf[..body_len].to_vec(); + Ok((frame, payload)) + } + + /// Write data to the stream. + fn write_all(&mut self, data: &[u8]) -> Result<()> { + let stream = self.stream.as_mut().ok_or(Error::NotConnected)?; + let total = data.len(); + let mut written = 0; + while written < total { + let n = stream.write(&data[written..])?; + if n == 0 { + return Err(Error::ConnectionFailed("broken pipe".to_string())); + } + written += n; + } + Ok(()) + } + + /// Gracefully close the connection to the server. + /// + /// Sends a CLOSE message to release server resources, + /// then shuts down the TCP connection. + pub fn close(&mut self) -> Result<()> { + if !matches!(self.state, State::Ready) { + return Ok(()); + } + let close = CloseMessage; + let payload = close.encode_payload(); + let _ = self.write_all(&build_message(CLOSE, self.handle, &payload)); + let _ = self.read_message(); + self.state = State::Closed; + Ok(()) + } + + /// Read output parameter values after executing a stored procedure. + /// + /// When a stored procedure is executed with OUTPUT or INPUT_OUTPUT parameters, + /// the server returns the output values in the EXEC_RESPONSE frame. This method + /// extracts those raw byte values so they can be decoded with + /// `parse_output_param_value()`. + /// + /// # Arguments + /// * `params` - The original `BindParam` slice used in the execute call. + /// Only parameters with `direction` of `Output` or `InputOutput` are included. + /// + /// # Returns + /// A vector of `(type_code, raw_bytes)` tuples, one per output parameter, + /// in the same order as the input parameters. Empty byte vectors indicate NULL. + pub fn read_output_params(&self, params: &[BindParam]) -> Vec<(i32, Vec)> { + params + .iter() + .filter(|p| { + p.direction == dameng_protocol::message::bind::ParameterDirection::Output + || p.direction + == dameng_protocol::message::bind::ParameterDirection::InputOutput + }) + .map(|p| { + let raw = p.value.clone().unwrap_or_default(); + (p.type_code, raw) + }) + .collect() + } + + /// Read the full content of a LOB (CLOB/BLOB) identified by a locator. + /// + /// This method first gets the LOB length via LOBGETLEN (msg_type=31), + /// then reads the content in chunks via LOBREAD (msg_type=32). + /// + /// Returns `Ok(String)` for CLOB or `Ok(Vec)` for BLOB. + /// + /// **Important**: The LOB locator is only valid within the current transaction. + /// If auto_commit is enabled, the locator may be invalidated after the query + /// that produced it is committed. In that case, disable auto_commit before + /// calling this method. + pub fn read_lob(&mut self, locator: &dameng_types::LobLocator) -> Result> { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + + // Step 1: Get LOB length via LOBGETLEN (msg_type=31) + let getlen_msg = LobGetLenMessage::new(locator.clone()); + let getlen_payload = getlen_msg.encode_payload(self.new_lob_flag); + self.write_all(&build_message(LOB_GETLEN, self.handle, &getlen_payload))?; + let (getlen_frame, getlen_resp_payload) = self.read_message()?; + if getlen_frame.response_code < 0 { + return Err(Error::QueryFailed(format!( + "LOBGETLEN failed: code={} type={}", + getlen_frame.response_code, getlen_frame.msg_type + ))); + } + let getlen_resp = LobGetLenResponse::from_bytes(&getlen_resp_payload)?; + let total_len = checked_lob_len(getlen_resp.length)?; + + if total_len == 0 { + return Ok(vec![]); + } + + // Apply new_blob_id from server if provided (matching Go driver) + let mut cur_locator = locator.clone(); + if let Some(new_id) = getlen_resp.new_blob_id { + // Update blob_id in the raw NBLOB_HEAD (offset 1, 8 bytes) + if cur_locator.raw.len() >= 9 { + cur_locator.raw[1..9].copy_from_slice(&new_id.to_le_bytes()); + } + } + cur_locator.init_cursor(); + + // Step 2: Read LOB data in chunks via LOBREAD (msg_type=32) + // Max chunk: 16384 bytes for BLOB, 8192 chars for CLOB + let max_chunk = if locator.is_clob { 8192 } else { 16384 }; + + let mut result = Vec::with_capacity(total_len); + let mut position: i32 = 0; + cur_locator.init_cursor(); + + while (position as usize) < total_len { + let remaining = total_len - position as usize; + let chunk_size = std::cmp::min(remaining, max_chunk) as i32; + + // Send LOBREAD + let read_msg = + LobReadMessage::new(cur_locator.clone(), position, chunk_size, self.new_lob_flag); + let read_payload = read_msg.encode_payload(); + self.write_all(&build_message(LOB_READ, self.handle, &read_payload))?; + let (read_frame, read_resp_payload) = self.read_message()?; + if read_frame.response_code < 0 { + return Err(Error::QueryFailed(format!( + "LOBREAD failed at pos {}: code={} type={}", + position, read_frame.response_code, read_frame.msg_type + ))); + } + let read_resp = LobReadResponse::from_bytes(&read_resp_payload)?; + + if read_resp.data.is_empty() { + break; + } + + if result.len().saturating_add(read_resp.data.len()) > MAX_LOB_BYTES { + return Err(Error::Protocol(dameng_protocol::Error::InvalidFrame( + format!("LOB content exceeds {MAX_LOB_BYTES} bytes"), + ))); + } + result.extend_from_slice(&read_resp.data); + + // For CLOB: advance by character count (charLen if available) + if locator.is_clob && read_resp.char_len > 0 { + position += read_resp.char_len as i32; + } else { + position += read_resp.data.len() as i32; + } + + // Update cursor state from response for next LOBREAD + cur_locator.update_cursor( + read_resp.cur_file_id, + read_resp.cur_page_no, + read_resp.total_offset, + ); + + if read_resp.read_over { + break; + } + } + + Ok(result) + } + + /// Free a LOB locator on the server via LOBFREE (msg_type=29). + /// + /// After reading a LOB's data, this releases the server-side LOB handle. + /// This is especially important for long-lived connections where LOB + /// handles could accumulate on the server. + pub fn free_lob(&mut self, locator: &dameng_types::LobLocator) -> Result<()> { + if !matches!(self.state, State::Ready) { + return Err(Error::NotConnected); + } + + let free_msg = LobFreeMessage::new(locator.clone()); + let free_payload = free_msg.encode_payload(self.new_lob_flag); + self.write_all(&build_message(LOB_FREE, self.handle, &free_payload))?; + let (free_frame, _) = self.read_message()?; + if free_frame.response_code < 0 { + return Err(Error::QueryFailed(format!( + "LOBFREE failed: code={} type={}", + free_frame.response_code, free_frame.msg_type + ))); + } + + Ok(()) + } +} + +impl Drop for Client { + fn drop(&mut self) { + if matches!(self.state, State::Ready) { + let _ = self.close(); + } + if let Some(mut stream) = self.stream.take() { + let _ = stream.shutdown(std::net::Shutdown::Both); + } + self.state = State::Closed; + } +} + +/// Build a complete message (frame + payload). +pub fn build_message(msg_type: u8, handle: u32, payload: &[u8]) -> Vec { + let frame = Frame::new(msg_type, handle, payload.len() as i32); + let mut result = frame.encode().to_vec(); + result.extend_from_slice(payload); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_new() { + let client = Client::new("localhost", 5236); + assert_eq!(client.host, "localhost"); + assert_eq!(client.port, 5236); + assert_eq!(client.state, State::Closed); + } + + #[test] + fn test_build_message_size() { + let msg = build_message(5, 1, b"SELECT 1"); + assert_eq!(msg.len(), FRAME_HEADER_SIZE + 8); + } + + #[test] + fn test_build_message_frame() { + let msg = build_message(200, 0, &[0u8; 10]); + let mut buf = BytesMut::from(&msg[..]); + let frame = Frame::parse(&mut buf).unwrap(); + assert_eq!(frame.msg_type, 200); + assert_eq!(frame.handle, 0); + assert_eq!(frame.body_len, 10); + } + + #[test] + fn test_state_transitions() { + let client = Client::new("test", 5236); + assert_eq!(client.state, State::Closed); + } + + #[test] + fn test_execute_not_connected() { + let mut client = Client::new("test", 5236); + let result = client.execute("SELECT 1"); + assert!(matches!(result, Err(Error::NotConnected))); + } + + #[test] + fn test_ready_not_connected() { + let mut client = Client::new("test", 5236); + let result = client.ready(); + assert!(matches!(result, Err(Error::NotConnected))); + } + + #[test] + fn test_lob_length_is_bounded() { + assert_eq!( + checked_lob_len(MAX_LOB_BYTES as i64).unwrap(), + MAX_LOB_BYTES + ); + assert!(checked_lob_len(-1).is_err()); + assert!(checked_lob_len(MAX_LOB_BYTES as i64 + 1).is_err()); + } +} diff --git a/Native/DamengBridge/Vendor/dameng/src/config.rs b/Native/DamengBridge/Vendor/dameng/src/config.rs new file mode 100644 index 000000000..3baf1e180 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/src/config.rs @@ -0,0 +1,362 @@ +//! Connection configuration options. +//! +//! Provides `ConnectOptions` for configuring Dameng database connections +//! with charset, schema, timezone, SSL, and other parameters. +//! Also provides DSN string parsing for convenient connection setup. + +use std::time::Duration; + +use dameng_protocol::message::isolation::IsolationLevel; + +/// Connection configuration options for Dameng database. +#[derive(Debug, Clone)] +pub struct ConnectOptions { + /// Database host (required). + pub host: String, + /// Database port (default: 5236). + pub port: u16, + /// Username (required). + pub username: String, + /// Password (required). + pub password: String, + /// Character set (e.g., "utf8", "gb18030"). + pub charset: Option, + /// Database schema. + pub schema: Option, + /// Timezone offset in hours. + pub timezone: Option, + /// Enable SSL/TLS encryption. + pub ssl: bool, + /// Maximum row size. + pub max_row_size: Option, + /// Connection timeout. + pub connect_timeout: Option, + /// Auto-commit mode (default: true). + pub auto_commit: bool, + /// Transaction isolation level (default: ReadCommitted). + pub isolation_level: IsolationLevel, +} + +impl ConnectOptions { + /// Create new ConnectOptions with required fields. + pub fn new(host: &str, port: u16, username: &str, password: &str) -> Self { + Self { + host: host.to_string(), + port, + username: username.to_string(), + password: password.to_string(), + charset: None, + schema: None, + timezone: None, + ssl: false, + max_row_size: None, + connect_timeout: None, + auto_commit: true, + isolation_level: IsolationLevel::ReadCommitted, + } + } + + /// Set the character set. + pub fn charset(mut self, charset: &str) -> Self { + self.charset = Some(charset.to_string()); + self + } + + /// Set the database schema. + pub fn schema(mut self, schema: &str) -> Self { + self.schema = Some(schema.to_string()); + self + } + + /// Set the timezone offset in hours. + pub fn timezone(mut self, timezone: i16) -> Self { + self.timezone = Some(timezone); + self + } + + /// Enable SSL/TLS encryption. + pub fn ssl(mut self, ssl: bool) -> Self { + self.ssl = ssl; + self + } + + /// Set the maximum row size. + pub fn max_row_size(mut self, max_row_size: i32) -> Self { + self.max_row_size = Some(max_row_size); + self + } + + /// Set the connection timeout. + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = Some(timeout); + self + } + + /// Set the auto-commit mode. + pub fn auto_commit(mut self, auto_commit: bool) -> Self { + self.auto_commit = auto_commit; + self + } + + /// Set the transaction isolation level. + pub fn isolation_level(mut self, level: IsolationLevel) -> Self { + self.isolation_level = level; + self + } + + /// Parse a DSN string into ConnectOptions. + /// + /// DSN format: `dm://username:password@host:port/schema?param1=value1¶m2=value2` + /// + /// Supported query parameters: + /// - `charset`: Character set (e.g., "utf8", "gb18030") + /// - `timezone`: Timezone offset in hours + /// - `ssl`: Enable SSL ("true" or "false") + /// - `max_row_size`: Maximum row size + /// - `connect_timeout`: Connection timeout in seconds + /// - `auto_commit`: Auto-commit mode ("true" or "false") + /// - `isolation_level`: Transaction isolation level ("read_uncommitted", "read_committed", + /// "repeatable_read", "serializable") + /// + /// # Examples + /// + /// ```ignore + /// let opts = ConnectOptions::from_dsn( + /// "dm://SYSDBA:SYSDBA@127.0.0.1:5236/?charset=utf8&auto_commit=true" + /// ).unwrap(); + /// ``` + pub fn from_dsn(dsn: &str) -> crate::error::Result { + use crate::error::Error; + + // Parse scheme + let (uri, _scheme) = if let Some(rest) = dsn.strip_prefix("dm://") { + (rest, "dm") + } else if let Some(rest) = dsn.strip_prefix("dm") { + (rest, "dm") + } else { + return Err(Error::ConfigError( + "invalid DSN: missing 'dm://' scheme".to_string(), + )); + }; + + // Parse query parameters + let (uri, query_params) = if let Some((before, after)) = uri.split_once('?') { + (before, Self::parse_query_params(after)) + } else { + (uri, std::collections::HashMap::new()) + }; + + // Parse authority@host:port/schema + let (userinfo, hostport) = if let Some((before, after)) = uri.split_once('@') { + (Some(before), after) + } else { + (None, uri) + }; + + // Extract schema from hostport/schema + let (hostport, schema) = if let Some((hp, sc)) = hostport.split_once('/') { + (hp, Some(sc.to_string())) + } else { + (hostport, None) + }; + + // Parse host:port + let (host, port) = if let Some((h, p)) = hostport.rsplit_once(':') { + match p.parse::() { + Ok(port) => (h, port), + Err(_) => (hostport, 5236), + } + } else { + (hostport, 5236) + }; + + if host.is_empty() { + return Err(Error::ConfigError("invalid DSN: missing host".to_string())); + } + + // Parse username:password + let (username, password) = if let Some(ui) = userinfo { + if let Some((u, p)) = ui.split_once(':') { + (u, p) + } else { + (ui, "") + } + } else { + ("", "") + }; + + let mut opts = ConnectOptions::new(host, port, username, password); + + // Apply schema from URL path (if present) + if let Some(sc) = schema { + opts.schema = Some(sc); + } + + // Apply query parameters (can override URL path values) + if let Some(charset) = query_params.get("charset") { + opts.charset = Some(charset.clone()); + } + if let Some(schema) = query_params.get("schema") { + opts.schema = Some(schema.clone()); + } + if let Some(tz) = query_params.get("timezone") { + if let Ok(tz) = tz.parse::() { + opts.timezone = Some(tz); + } + } + if let Some(ssl_str) = query_params.get("ssl") { + opts.ssl = ssl_str == "true"; + } + if let Some(mrs) = query_params.get("max_row_size") { + if let Ok(mrs) = mrs.parse::() { + opts.max_row_size = Some(mrs); + } + } + if let Some(ct) = query_params.get("connect_timeout") { + if let Ok(ct) = ct.parse::() { + opts.connect_timeout = Some(Duration::from_secs(ct)); + } + } + if let Some(ac) = query_params.get("auto_commit") { + opts.auto_commit = ac == "true"; + } + if let Some(iso) = query_params.get("isolation_level") { + if let Some(level) = match iso.as_str() { + "read_uncommitted" => Some(IsolationLevel::ReadUncommitted), + "read_committed" => Some(IsolationLevel::ReadCommitted), + "repeatable_read" => Some(IsolationLevel::RepeatableRead), + "serializable" => Some(IsolationLevel::Serializable), + _ => None, + } { + opts.isolation_level = level; + } + } + + Ok(opts) + } + + /// Parse query parameter string into a HashMap. + fn parse_query_params(params: &str) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + for pair in params.split('&') { + if let Some((key, value)) = pair.split_once('=') { + map.insert(key.to_string(), value.to_string()); + } + } + map + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_connect_options_new() { + let opts = ConnectOptions::new("127.0.0.1", 5236, "SYSDBA", "SYSDBA"); + assert_eq!(opts.host, "127.0.0.1"); + assert_eq!(opts.port, 5236); + assert_eq!(opts.username, "SYSDBA"); + assert_eq!(opts.password, "SYSDBA"); + assert_eq!(opts.charset, None); + assert_eq!(opts.schema, None); + assert!(!opts.ssl); + assert!(opts.auto_commit); + assert_eq!(opts.isolation_level, IsolationLevel::ReadCommitted); + } + + #[test] + fn test_connect_options_builder() { + let opts = ConnectOptions::new("127.0.0.1", 5236, "SYSDBA", "SYSDBA") + .charset("utf8") + .schema("TEST") + .timezone(8) + .ssl(true) + .max_row_size(8192) + .connect_timeout(Duration::from_secs(30)) + .auto_commit(false) + .isolation_level(IsolationLevel::Serializable); + + assert_eq!(opts.charset, Some("utf8".to_string())); + assert_eq!(opts.schema, Some("TEST".to_string())); + assert_eq!(opts.timezone, Some(8)); + assert!(opts.ssl); + assert_eq!(opts.max_row_size, Some(8192)); + assert_eq!(opts.connect_timeout, Some(Duration::from_secs(30))); + assert!(!opts.auto_commit); + assert_eq!(opts.isolation_level, IsolationLevel::Serializable); + } + + #[test] + fn test_dsn_basic() { + let opts = ConnectOptions::from_dsn("dm://SYSDBA:SYSDBA@127.0.0.1:5236/").unwrap(); + assert_eq!(opts.host, "127.0.0.1"); + assert_eq!(opts.port, 5236); + assert_eq!(opts.username, "SYSDBA"); + assert_eq!(opts.password, "SYSDBA"); + } + + #[test] + fn test_dsn_with_params() { + let opts = ConnectOptions::from_dsn( + "dm://SYSDBA:SYSDBA@127.0.0.1:5236/?charset=utf8&ssl=true&auto_commit=false", + ) + .unwrap(); + assert_eq!(opts.charset, Some("utf8".to_string())); + assert!(opts.ssl); + assert!(!opts.auto_commit); + } + + #[test] + fn test_dsn_with_schema() { + let opts = + ConnectOptions::from_dsn("dm://SYSDBA:SYSDBA@127.0.0.1:5236/TEST?charset=gb18030") + .unwrap(); + assert_eq!(opts.schema, Some("TEST".to_string())); + assert_eq!(opts.charset, Some("gb18030".to_string())); + } + + #[test] + fn test_dsn_default_port() { + let opts = ConnectOptions::from_dsn("dm://SYSDBA:SYSDBA@127.0.0.1").unwrap(); + assert_eq!(opts.host, "127.0.0.1"); + assert_eq!(opts.port, 5236); + } + + #[test] + fn test_dsn_isolation_level() { + let opts = ConnectOptions::from_dsn( + "dm://SYSDBA:SYSDBA@127.0.0.1:5236/?isolation_level=serializable", + ) + .unwrap(); + assert_eq!(opts.isolation_level, IsolationLevel::Serializable); + } + + #[test] + fn test_dsn_invalid_scheme() { + let result = ConnectOptions::from_dsn("mysql://SYSDBA:SYSDBA@127.0.0.1:5236/"); + assert!(result.is_err()); + } + + #[test] + fn test_dsn_missing_host() { + let result = ConnectOptions::from_dsn("dm://SYSDBA:SYSDBA@"); + assert!(result.is_err()); + } + + #[test] + fn test_dsn_connect_timeout() { + let opts = + ConnectOptions::from_dsn("dm://SYSDBA:SYSDBA@127.0.0.1:5236/?connect_timeout=60") + .unwrap(); + assert_eq!(opts.connect_timeout, Some(Duration::from_secs(60))); + } + + #[test] + fn test_dsn_max_row_size() { + let opts = + ConnectOptions::from_dsn("dm://SYSDBA:SYSDBA@127.0.0.1:5236/?max_row_size=16384") + .unwrap(); + assert_eq!(opts.max_row_size, Some(16384)); + } +} diff --git a/Native/DamengBridge/Vendor/dameng/src/error.rs b/Native/DamengBridge/Vendor/dameng/src/error.rs new file mode 100644 index 000000000..76ae19709 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/src/error.rs @@ -0,0 +1,72 @@ +//! Error types for the Dameng driver. + +use std::fmt; + +#[derive(Debug)] +pub enum Error { + Protocol(dameng_protocol::Error), + Io(std::io::Error), + ConnectionFailed(String), + AuthFailed(String), + ServerError(i32, String), + DecodeError(String), + QueryFailed(String), + NotConnected, + ConfigError(String), + /// Invalid transaction isolation level was specified. + InvalidIsolation(String), + /// LOB locator has been freed and can no longer be used. + LobFreed(String), + /// Date/time format parsing failed. + InvalidDateFormat(String), + /// Server returned a busy or overloaded state. + ServerBusy, + /// LOB read operation failed. + LobReadFailed(String), + /// LOB write operation failed. + LobWriteFailed(String), + /// Operation timed out. + Timeout(String), + /// Schema/database name resolution error. + SchemaError(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Protocol(e) => write!(f, "protocol error: {e}"), + Error::Io(e) => write!(f, "IO error: {e}"), + Error::ConnectionFailed(s) => write!(f, "connection failed: {s}"), + Error::AuthFailed(s) => write!(f, "auth failed: {s}"), + Error::ServerError(code, msg) => write!(f, "server error {code}: {msg}"), + Error::DecodeError(s) => write!(f, "decode error: {s}"), + Error::QueryFailed(s) => write!(f, "query failed: {s}"), + Error::NotConnected => write!(f, "not connected"), + Error::ConfigError(s) => write!(f, "config error: {s}"), + Error::InvalidIsolation(s) => write!(f, "invalid isolation level: {s}"), + Error::LobFreed(s) => write!(f, "LOB freed: {s}"), + Error::InvalidDateFormat(s) => write!(f, "invalid date format: {s}"), + Error::ServerBusy => write!(f, "server busy"), + Error::LobReadFailed(s) => write!(f, "LOB read failed: {s}"), + Error::LobWriteFailed(s) => write!(f, "LOB write failed: {s}"), + Error::Timeout(s) => write!(f, "timeout: {s}"), + Error::SchemaError(s) => write!(f, "schema error: {s}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: dameng_protocol::Error) -> Self { + Error::Protocol(e) + } +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +pub type Result = std::result::Result; diff --git a/Native/DamengBridge/Vendor/dameng/src/lib.rs b/Native/DamengBridge/Vendor/dameng/src/lib.rs new file mode 100644 index 000000000..231d76b97 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/src/lib.rs @@ -0,0 +1,28 @@ +//! Dameng database sync driver. +//! +//! Provides synchronous connection and query execution against +//! Dameng database servers. + +pub mod client; +pub mod config; +pub mod error; +pub mod row; +pub mod transaction; + +pub use client::Client; +pub use config::ConnectOptions; +pub use dameng_protocol::Row; +pub use error::{Error, Result}; + +// Re-export protocol types needed for parameter binding +pub use dameng_protocol::message::isolation::{IsolationLevel, SetIsolationMessage}; +pub use dameng_protocol::message::{BindParam, ParameterDirection}; + +// Re-export ToDmValue trait for SQLx-style dynamic binding +pub use dameng_types::ToDmValue; + +// Re-export row types +pub use row::{DmDecode, QueryRow, QueryRowRef, ResultSet, ResultSetIter}; + +// Re-export Transaction +pub use transaction::Transaction; diff --git a/Native/DamengBridge/Vendor/dameng/src/row.rs b/Native/DamengBridge/Vendor/dameng/src/row.rs new file mode 100644 index 000000000..e998b0e2d --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/src/row.rs @@ -0,0 +1,876 @@ +//! Row representation for query results. +//! +//! Provides SQLx-style `row.get::(idx)` API and iterator support. + +use std::ops::Deref; +use std::str::FromStr; + +use dameng_protocol::Row; + +pub use dameng_protocol::Column; + +/// A query result set containing columns and rows. +#[derive(Debug, Clone)] +pub struct ResultSet { + /// Column metadata shared across all rows. + pub columns: Vec, + /// Row data. + pub rows: Vec, + /// Result set cursor ID (from the initial query). + pub cursor_id: i16, + /// Total row count in the result set (from server). + pub total_row_count: u64, +} + +/// A single row with column metadata, produced by iterating a `ResultSet`. +/// +/// Supports SQLx-style `row.get::(idx)` for type-safe column access, +/// and `row.get_str_ref(idx)` / `row.get_opt_str_ref(idx)` for borrowed string access. +#[derive(Debug, Clone)] +pub struct QueryRow { + /// The underlying raw row data. + pub row: Row, + /// Column metadata for decoding values. + pub columns: Vec, +} + +/// A row with referenced column metadata (borrowed iteration). +#[derive(Debug, Clone)] +pub struct QueryRowRef<'a> { + /// The underlying raw row data. + pub row: &'a Row, + /// Column metadata reference. + pub columns: &'a [Column], +} + +impl<'a> Deref for QueryRowRef<'a> { + type Target = Row; + fn deref(&self) -> &Self::Target { + self.row + } +} + +// ─── IntoIterator for ResultSet (consuming) ───────────────────────────────── + +impl IntoIterator for ResultSet { + type Item = QueryRow; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + let columns = self.columns; + let qrows: Vec = self + .rows + .into_iter() + .map(|row| QueryRow { + row, + columns: columns.clone(), + }) + .collect(); + qrows.into_iter() + } +} + +// ─── IntoIterator for &ResultSet (borrowing) ──────────────────────────────── + +impl<'a> IntoIterator for &'a ResultSet { + type Item = QueryRowRef<'a>; + type IntoIter = ResultSetIter<'a>; + + fn into_iter(self) -> Self::IntoIter { + ResultSetIter { + result_set: self, + current: 0, + } + } +} + +/// Borrowing iterator over rows in a ResultSet. +pub struct ResultSetIter<'a> { + result_set: &'a ResultSet, + current: usize, +} + +impl<'a> Iterator for ResultSetIter<'a> { + type Item = QueryRowRef<'a>; + + fn next(&mut self) -> Option { + if self.current >= self.result_set.rows.len() { + return None; + } + let row = &self.result_set.rows[self.current]; + self.current += 1; + Some(QueryRowRef { + row, + columns: &self.result_set.columns, + }) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.result_set.rows.len() - self.current; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for ResultSetIter<'_> {} + +// ─── DmDecode trait ───────────────────────────────────────────────────────── + +/// Decode a column value from its raw bytes into a Rust type. +/// +/// The lifetime `'de` allows borrowing the raw bytes (e.g., for `&str`). +pub trait DmDecode<'de>: Sized { + /// Decode from an optional byte slice. + /// `None` means NULL, `Some(&[])` means an empty (non-NULL) value. + fn decode(value: Option<&'de [u8]>) -> crate::error::Result; +} + +impl<'de> DmDecode<'de> for bool { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + Ok(bytes[0] != 0) + } +} + +impl<'de> DmDecode<'de> for i32 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + if bytes.len() < 4 { + if bytes.len() == 1 { + return Ok(bytes[0] as i32); + } + if bytes.len() == 2 { + return Ok(i32::from(i16::from_le_bytes([bytes[0], bytes[1]]))); + } + return Err(crate::error::Error::DecodeError(format!( + "too short for i32: {} bytes", + bytes.len() + ))); + } + let arr: [u8; 4] = bytes[..4].try_into().unwrap(); + Ok(i32::from_le_bytes(arr)) + } +} + +impl<'de> DmDecode<'de> for i64 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + if bytes.len() < 8 { + if bytes.len() >= 4 { + let arr: [u8; 4] = bytes[..4].try_into().unwrap(); + return Ok(i64::from(i32::from_le_bytes(arr))); + } + return Err(crate::error::Error::DecodeError(format!( + "too short for i64: {} bytes", + bytes.len() + ))); + } + let arr: [u8; 8] = bytes[..8].try_into().unwrap(); + Ok(i64::from_le_bytes(arr)) + } +} + +impl<'de> DmDecode<'de> for i16 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + if bytes.len() < 2 { + if bytes.len() == 1 { + return Ok(bytes[0] as i16); + } + return Err(crate::error::Error::DecodeError(format!( + "too short for i16: {} bytes", + bytes.len() + ))); + } + Ok(i16::from_le_bytes([bytes[0], bytes[1]])) + } +} + +impl<'de> DmDecode<'de> for i8 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column is NULL".to_string(), + )); + } + Ok(bytes[0] as i8) + } +} + +impl<'de> DmDecode<'de> for u32 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + if bytes.len() < 4 { + if bytes.len() == 1 { + return Ok(bytes[0] as u32); + } + if bytes.len() == 2 { + return Ok(u16::from_le_bytes([bytes[0], bytes[1]]) as u32); + } + return Err(crate::error::Error::DecodeError(format!( + "too short for u32: {} bytes", + bytes.len() + ))); + } + let arr: [u8; 4] = bytes[..4].try_into().unwrap(); + Ok(u32::from_le_bytes(arr)) + } +} + +impl<'de> DmDecode<'de> for u64 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + if bytes.len() < 8 { + if bytes.len() >= 4 { + let arr: [u8; 4] = bytes[..4].try_into().unwrap(); + return Ok(u32::from_le_bytes(arr) as u64); + } + return Err(crate::error::Error::DecodeError(format!( + "too short for u64: {} bytes", + bytes.len() + ))); + } + let arr: [u8; 8] = bytes[..8].try_into().unwrap(); + Ok(u64::from_le_bytes(arr)) + } +} + +impl<'de> DmDecode<'de> for u16 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + if bytes.len() < 2 { + if bytes.len() == 1 { + return Ok(bytes[0] as u16); + } + return Err(crate::error::Error::DecodeError(format!( + "too short for u16: {} bytes", + bytes.len() + ))); + } + Ok(u16::from_le_bytes([bytes[0], bytes[1]])) + } +} + +impl<'de> DmDecode<'de> for u8 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column is NULL".to_string(), + )); + } + Ok(bytes[0]) + } +} + +impl<'de> DmDecode<'de> for f64 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.len() < 8 { + return Err(crate::error::Error::DecodeError(format!( + "too short for f64: {} bytes", + bytes.len() + ))); + } + let arr: [u8; 8] = bytes[..8].try_into().unwrap(); + Ok(f64::from_le_bytes(arr)) + } +} + +impl<'de> DmDecode<'de> for f32 { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.len() < 4 { + return Err(crate::error::Error::DecodeError(format!( + "too short for f32: {} bytes", + bytes.len() + ))); + } + let arr: [u8; 4] = bytes[..4].try_into().unwrap(); + Ok(f32::from_le_bytes(arr)) + } +} + +/// Returns a borrowed string from the row's raw value bytes. +impl<'de> DmDecode<'de> for &'de str { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Ok(""); + } + std::str::from_utf8(bytes) + .map_err(|e| crate::error::Error::DecodeError(format!("invalid UTF-8: {}", e))) + } +} + +/// Returns an owned String. +impl<'de> DmDecode<'de> for String { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + Ok(String::from_utf8_lossy(bytes).into_owned()) + } +} + +/// Returns a Decimal from DECIMAL type (text, already decoded in response parser). +impl<'de> DmDecode<'de> for rust_decimal::Decimal { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + let s = std::str::from_utf8(bytes).map_err(|_| { + crate::error::Error::DecodeError("DECIMAL is not valid UTF-8".to_string()) + })?; + let trimmed = s.trim(); + if trimmed.is_empty() || trimmed == "0" { + return Ok(rust_decimal::Decimal::ZERO); + } + rust_decimal::Decimal::from_str(trimmed).map_err(|e| { + crate::error::Error::DecodeError(format!("invalid DECIMAL '{}' : {}", trimmed, e)) + }) + } +} + +/// Returns a NaiveDate from DATE type. +impl<'de> DmDecode<'de> for chrono::NaiveDate { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + // Try text format first + if let Ok(s) = std::str::from_utf8(bytes) { + if let Ok(d) = chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d") { + return Ok(d); + } + } + // Try binary format (7 bytes: year:2BE, month:1, day:1, hour:1, min:1, sec:1) + // Try 3-byte DM row format (DATE_PREC = 3, year+month+day compressed) + if bytes.len() >= 3 && bytes.len() < 7 { + let year = i32::from(i16::from_le_bytes([bytes[0], bytes[1]])) & 0x7FFF; + let month = ((bytes[1] as u32 >> 7) & 0x1) + ((bytes[2] as u32 & 0x07) << 1); + let day = ((bytes[2] as u32 & 0xF8) >> 3) & 0x1F; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + return Ok(d); + } + } + // Try 7-byte OPE format + if bytes.len() >= 7 { + let year = u16::from_be_bytes([bytes[0], bytes[1]]) as i32; + let month = bytes[2] as u32; + let day = bytes[3] as u32; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + return Ok(d); + } + } + // Try 8-byte DM row format (DATE_PREC = 7 but stored in 8 bytes) + if bytes.len() >= 8 { + let year = i32::from(i16::from_le_bytes([bytes[0], bytes[1]])) & 0x7FFF; + let month = ((bytes[1] as u32 >> 7) & 0x1) + ((bytes[2] as u32 & 0x07) << 1); + let day = ((bytes[2] as u32 & 0xF8) >> 3) & 0x1F; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) { + return Ok(d); + } + } + Err(crate::error::Error::DecodeError( + "too short for DATE".to_string(), + )) + } +} + +/// Returns a NaiveDateTime from TIMESTAMP type. +impl<'de> DmDecode<'de> for chrono::NaiveDateTime { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + let bytes = + value.ok_or_else(|| crate::error::Error::DecodeError("column is NULL".to_string()))?; + if bytes.is_empty() { + return Err(crate::error::Error::DecodeError( + "column value is empty".to_string(), + )); + } + // Try text format first + if let Ok(s) = std::str::from_utf8(bytes) { + let s = s.trim(); + if let Ok(ts) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return Ok(ts); + } + if let Ok(ts) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { + return Ok(ts); + } + } + // Try 11-byte OPE format (year:2BE, month:1, day:1, hour:1, min:1, sec:1, nano:4BE) + if bytes.len() >= 11 { + let year = u16::from_be_bytes([bytes[0], bytes[1]]) as i32; + let month = bytes[2] as u32; + let day = bytes[3] as u32; + let hour = bytes[4] as u32; + let minute = bytes[5] as u32; + let second = bytes[6] as u32; + let nano = u32::from_be_bytes([bytes[7], bytes[8], bytes[9], bytes[10]]); + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) + .and_then(|d| d.and_hms_nano_opt(hour, minute, second, nano)) + { + return Ok(d); + } + } + // Try 8-byte DM row format (DATETIME_PREC) + if bytes.len() >= 8 { + let year = i32::from(i16::from_le_bytes([bytes[0], bytes[1]])) & 0x7FFF; + let month = ((bytes[1] as u32 >> 7) & 0x1) + ((bytes[2] as u32 & 0x07) << 1); + let day = ((bytes[2] as u32 & 0xF8) >> 3) & 0x1F; + let hour = bytes[3] as u32 & 0x1F; + let minute = ((bytes[3] as u32 >> 5) & 0x07) + ((bytes[4] as u32 & 0x07) << 3); + let second = ((bytes[4] as u32 >> 3) & 0x1F) + ((bytes[5] as u32 & 0x01) << 5); + let nano = (((bytes[5] as u32 >> 1) & 0x7F) + + ((bytes[6] as u32 & 0xFF) << 7) + + ((bytes[7] as u32 & 0x1F) << 15)) + * 1000; + if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) + .and_then(|d| d.and_hms_nano_opt(hour, minute, second, nano)) + { + return Ok(d); + } + } + Err(crate::error::Error::DecodeError( + "too short for TIMESTAMP".to_string(), + )) + } +} + +impl<'de> DmDecode<'de> for Vec { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) => Ok(bytes.to_vec()), + None => Ok(vec![]), + } + } +} + +// ─── Option support ────────────────────────────────────────────────────── + +macro_rules! impl_dm_decode_option { + ($inner:ty) => { + impl<'de> DmDecode<'de> for Option<$inner> { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) if !bytes.is_empty() => { + <$inner as DmDecode>::decode(Some(bytes)).map(Some) + } + _ => Ok(None), + } + } + } + }; +} + +impl_dm_decode_option!(bool); +impl_dm_decode_option!(i32); +impl_dm_decode_option!(i64); +impl_dm_decode_option!(i16); +impl_dm_decode_option!(i8); +impl_dm_decode_option!(u32); +impl_dm_decode_option!(u64); +impl_dm_decode_option!(u16); +impl_dm_decode_option!(u8); +impl_dm_decode_option!(f64); +impl_dm_decode_option!(f32); + +impl<'de> DmDecode<'de> for Option<&'de str> { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) if !bytes.is_empty() => <&str as DmDecode>::decode(Some(bytes)).map(Some), + _ => Ok(None), + } + } +} + +impl<'de> DmDecode<'de> for Option { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) if !bytes.is_empty() => ::decode(Some(bytes)).map(Some), + _ => Ok(None), + } + } +} + +impl<'de> DmDecode<'de> for Option> { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) if !bytes.is_empty() => Ok(Some(bytes.to_vec())), + _ => Ok(None), + } + } +} + +impl<'de> DmDecode<'de> for Option { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) if !bytes.is_empty() => { + ::decode(Some(bytes)).map(Some) + } + _ => Ok(None), + } + } +} + +impl<'de> DmDecode<'de> for Option { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) if !bytes.is_empty() => { + ::decode(Some(bytes)).map(Some) + } + _ => Ok(None), + } + } +} + +impl<'de> DmDecode<'de> for Option { + fn decode(value: Option<&'de [u8]>) -> crate::error::Result { + match value { + Some(bytes) if !bytes.is_empty() => { + ::decode(Some(bytes)).map(Some) + } + _ => Ok(None), + } + } +} + +// ─── QueryRow methods ─────────────────────────────────────────────────────── + +impl QueryRow { + /// Get a decoded value at the given column index. + /// + /// Supports SQLx-style type inference: + /// ```ignore + /// let id: i32 = row.get(0)?; + /// let name: &str = row.get(1)?; + /// let addr: Option<&str> = row.get(2)?; + /// ``` + pub fn get<'de, T: DmDecode<'de>>(&'de self, idx: usize) -> crate::error::Result { + let value = self.row.values.get(idx).and_then(|v| v.as_deref()); + T::decode(value) + } +} + +impl<'a> QueryRowRef<'a> { + /// Get a decoded value at the given column index. + pub fn get<'de, T: DmDecode<'de>>(&'de self, idx: usize) -> crate::error::Result + where + 'a: 'de, + { + let value = self.row.values.get(idx).and_then(|v| v.as_deref()); + T::decode(value) + } +} + +// ─── ResultSet methods ────────────────────────────────────────────────────── + +impl ResultSet { + /// Create a new empty result set. + pub fn new() -> Self { + Self { + columns: vec![], + rows: vec![], + cursor_id: 0, + total_row_count: 0, + } + } + + /// Create a result set with the given data. + pub fn with_data( + columns: Vec, + rows: Vec, + cursor_id: i16, + total_row_count: u64, + ) -> Self { + Self { + columns, + rows, + cursor_id, + total_row_count, + } + } + + /// Check if the result set is empty. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + /// Get the number of rows. + pub fn len(&self) -> usize { + self.rows.len() + } + + /// Get the first row, if any (returns a QueryRowRef with column metadata). + pub fn first(&self) -> Option> { + self.rows.first().map(|row| QueryRowRef { + row, + columns: &self.columns, + }) + } + + /// Iterate over rows with column metadata (borrowing). + /// + /// Supports SQLx-style type inference: + /// ```ignore + /// for row in rs.iter() { + /// let id: i32 = row.get(0)?; + /// let name: &str = row.get(1)?; + /// } + /// ``` + /// + /// Also supports protocol-level methods via `Deref`: + /// ```ignore + /// for row in rs.iter() { + /// let id = row.get_i32(0)?; + /// let name = row.get_str(1)?; + /// } + /// ``` + pub fn iter(&self) -> ResultSetIter<'_> { + ResultSetIter { + result_set: self, + current: 0, + } + } + + /// Iterate over rows with access to column metadata (borrowing). + /// Alias for `iter()`. + pub fn iter_rows(&self) -> ResultSetIter<'_> { + self.iter() + } + + /// Get column metadata by name. + pub fn column_by_name(&self, name: &str) -> Option<&Column> { + self.columns.iter().find(|c| c.name == name) + } + + /// Check if there are more rows to fetch. + pub fn has_more(&self) -> bool { + self.rows.len() < self.total_row_count as usize + } + + /// Get the next fetch start position. + pub fn next_fetch_start(&self) -> usize { + self.rows.len() + } +} + +impl Default for ResultSet { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_row_empty() { + let row = Row { + row_id: 0, + values: vec![], + }; + assert!(row.is_empty()); + assert_eq!(row.len(), 0); + } + + #[test] + fn test_result_set_empty() { + let rs = ResultSet::new(); + assert!(rs.is_empty()); + assert_eq!(rs.len(), 0); + } + + #[test] + fn test_query_row_get_i32() { + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![Some(vec![100, 0, 0, 0])], + }, + columns: vec![], + }; + assert_eq!(qrow.get::(0).unwrap(), 100); + } + + #[test] + fn test_query_row_get_str() { + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![Some(b"Alice".to_vec())], + }, + columns: vec![], + }; + assert_eq!(qrow.get::<&str>(0).unwrap(), "Alice"); + } + + #[test] + fn test_query_row_get_option() { + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![None, Some(vec![1, 0, 0, 0])], + }, + columns: vec![], + }; + assert_eq!(qrow.get::>(0).unwrap(), None); + assert_eq!(qrow.get::>(1).unwrap(), Some(1)); + } + + #[test] + fn test_query_row_get_opt_str() { + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![None, Some(b"Alice".to_vec())], + }, + columns: vec![], + }; + assert_eq!(qrow.get::>(0).unwrap(), None); + assert_eq!(qrow.get::>(1).unwrap(), Some("Alice")); + } + + #[test] + fn test_result_set_into_iter() { + let rs = ResultSet::with_data( + vec![], + vec![ + Row { + row_id: 0, + values: vec![Some(vec![1, 0, 0, 0])], + }, + Row { + row_id: 1, + values: vec![Some(vec![2, 0, 0, 0])], + }, + ], + 0, + 2, + ); + let ids: Vec = rs.into_iter().map(|r| r.get::(0).unwrap()).collect(); + assert_eq!(ids, vec![1, 2]); + } + + #[test] + fn test_query_row_get_u32() { + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![Some(vec![100, 0, 0, 0])], + }, + columns: vec![], + }; + assert_eq!(qrow.get::(0).unwrap(), 100u32); + } + + #[test] + fn test_query_row_get_u64() { + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![Some(vec![0xe8, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])], + }, + columns: vec![], + }; + assert_eq!(qrow.get::(0).unwrap(), 1000u64); + } + + #[test] + fn test_query_row_get_bool() { + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![Some(vec![1]), Some(vec![0])], + }, + columns: vec![], + }; + assert_eq!(qrow.get::(0).unwrap(), true); + assert_eq!(qrow.get::(1).unwrap(), false); + } + + #[test] + fn test_query_row_deref_get_str() { + // Test that Deref works for protocol-level methods + let qrow = QueryRow { + row: Row { + row_id: 0, + values: vec![Some(b"Hello".to_vec())], + }, + columns: vec![], + }; + // get_str is on Row, accessible via row.field + assert_eq!(qrow.row.get_str(0).unwrap(), "Hello"); + } + + #[test] + fn test_result_set_iter_deref() { + // Test that rs.iter() returning QueryRowRef still supports + // protocol-level methods via Deref + let rs = ResultSet::with_data( + vec![], + vec![Row { + row_id: 0, + values: vec![Some(vec![1, 0, 0, 0]), Some(b"Alice".to_vec())], + }], + 0, + 1, + ); + for row in rs.iter() { + let id = row.get_i32(0).unwrap(); + let name = row.get_str(1).unwrap(); + assert_eq!(id, 1); + assert_eq!(name, "Alice"); + } + } +} diff --git a/Native/DamengBridge/Vendor/dameng/src/transaction.rs b/Native/DamengBridge/Vendor/dameng/src/transaction.rs new file mode 100644 index 000000000..6f31faf41 --- /dev/null +++ b/Native/DamengBridge/Vendor/dameng/src/transaction.rs @@ -0,0 +1,83 @@ +//! Transaction support — follows rust-postgres / sqlx patterns. +//! +//! A `Transaction` borrows a `Client` in a database transaction. +//! All operations on the `Transaction` are executed within the transaction. +//! On `Drop`, the transaction is automatically rolled back if not committed. +//! +//! `commit()` and `rollback()` take `self` (ownership), which releases +//! the mutable borrow on `Client` so it can be used again immediately. + +use crate::client::Client; +use crate::error::Result; +use crate::row::ResultSet; +use dameng_types::ToDmValue; + +pub struct Transaction<'a> { + client: &'a mut Client, + finished: bool, +} + +impl<'a> Transaction<'a> { + /// Commit the transaction, consuming `self` and releasing the Client borrow. + pub fn commit(mut self) -> Result<()> { + self.finish_inner("COMMIT") + } + + /// Roll back the transaction, consuming `self` and releasing the Client borrow. + pub fn rollback(mut self) -> Result<()> { + self.finish_inner("ROLLBACK") + } + + /// Execute a DML statement within the transaction. Returns affected rows. + pub fn execute(&mut self, sql: &str) -> Result { + self.client.execute(sql) + } + + /// Execute DML with dynamic parameters within the transaction. + pub fn execute_with_params(&mut self, sql: &str, params: &[&dyn ToDmValue]) -> Result { + self.client.execute_with_params(sql, params) + } + + /// Execute a SELECT query within the transaction. + pub fn query(&mut self, sql: &str) -> Result { + self.client.query(sql) + } + + /// Execute a SELECT query with dynamic parameters within the transaction. + pub fn query_with_params(&mut self, sql: &str, params: &[&dyn ToDmValue]) -> Result { + self.client.query_with_params(sql, params) + } + + fn finish_inner(&mut self, command: &str) -> Result<()> { + self.client.execute(command)?; + self.client.auto_commit = true; + self.finished = true; + Ok(()) + } +} + +impl<'a> Drop for Transaction<'a> { + fn drop(&mut self) { + if !self.finished { + let _ = self.client.execute("ROLLBACK"); + self.client.auto_commit = true; + } + } +} + +impl Client { + /// Begin a new transaction, returning a `Transaction` handle. + /// + /// After `commit()` or `rollback()` (which take `self`), the mutable + /// borrow on `Client` is released and the `Client` can be used again. + /// + /// If the `Transaction` is dropped without commit/rollback, a + /// `ROLLBACK` is sent automatically (best-effort). + pub fn transaction(&mut self) -> Result> { + self.begin()?; + Ok(Transaction { + client: self, + finished: false, + }) + } +} diff --git a/Native/DamengBridge/rust-toolchain.toml b/Native/DamengBridge/rust-toolchain.toml new file mode 100644 index 000000000..c0a424811 --- /dev/null +++ b/Native/DamengBridge/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.91.1" +profile = "minimal" +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"] diff --git a/Native/DamengBridge/src/lib.rs b/Native/DamengBridge/src/lib.rs new file mode 100644 index 000000000..12da87bd4 --- /dev/null +++ b/Native/DamengBridge/src/lib.rs @@ -0,0 +1,764 @@ +// Safety contracts for the exported C ABI are documented in CDameng.h, next to +// the declarations consumed by Swift. +#![allow(clippy::missing_safety_doc)] + +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; +use std::slice; +use std::str; +use std::time::Instant; + +use dameng::Client; +use dameng_types::encoding::{decode_from_server, ServerEncoding}; +use dameng_types::DmValue; + +const MAX_HOST_BYTES: usize = 1_024; +const MAX_CREDENTIAL_BYTES: usize = 4_096; +const MAX_SQL_BYTES: usize = 16 * 1_024 * 1_024; + +pub struct TpDmConnection { + client: Option, +} + +pub struct TpDmError { + message: Vec, +} + +pub struct TpDmResult { + columns: Vec, + rows: Vec>, + rows_affected: u64, + execution_time_seconds: f64, + is_truncated: bool, +} + +struct TpDmColumn { + name: Vec, + type_name: Vec, +} + +enum TpDmCell { + Null, + Text(Vec), + Bytes(Vec), +} + +fn set_error(error_out: *mut *mut TpDmError, message: impl Into) { + if error_out.is_null() { + return; + } + let error = Box::new(TpDmError { + message: message.into().into_bytes(), + }); + unsafe { + *error_out = Box::into_raw(error); + } +} + +fn clear_error(error_out: *mut *mut TpDmError) { + if error_out.is_null() { + return; + } + unsafe { + *error_out = ptr::null_mut(); + } +} + +fn panic_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + "Dameng transport panicked".to_string() +} + +unsafe fn required_string( + bytes: *const u8, + length: usize, + maximum: usize, + field: &str, +) -> Result { + if bytes.is_null() { + return Err(format!("{field} is missing")); + } + if length == 0 || length > maximum { + return Err(format!("{field} length is invalid")); + } + let value = slice::from_raw_parts(bytes, length); + str::from_utf8(value) + .map(str::to_owned) + .map_err(|_| format!("{field} is not valid UTF-8")) +} + +unsafe fn password_string(bytes: *const u8, length: usize) -> Result { + if length > MAX_CREDENTIAL_BYTES { + return Err("password length is invalid".to_string()); + } + if length == 0 { + return Ok(String::new()); + } + if bytes.is_null() { + return Err("password is missing".to_string()); + } + let value = slice::from_raw_parts(bytes, length); + str::from_utf8(value) + .map(str::to_owned) + .map_err(|_| "password is not valid UTF-8".to_string()) +} + +unsafe fn optional_sql(bytes: *const u8, length: usize) -> Result { + if bytes.is_null() || length == 0 || length > MAX_SQL_BYTES { + return Err("query length is invalid".to_string()); + } + let value = slice::from_raw_parts(bytes, length); + str::from_utf8(value) + .map(str::to_owned) + .map_err(|_| "query is not valid UTF-8".to_string()) +} + +fn detect_server_encoding(client: &mut Client) -> Result<(), String> { + let result = client + .query("SELECT UNICODE()") + .map_err(|error| error.to_string())?; + let row = result + .rows + .first() + .ok_or_else(|| "DM8 did not return its Unicode mode".to_string())?; + let flag = row.get_i32(0).map_err(|error| error.to_string())?; + client.server_encoding = if flag == 1 { + ServerEncoding::Utf8 + } else { + ServerEncoding::Gb18030 + }; + Ok(()) +} + +fn text_cell(value: impl ToString) -> TpDmCell { + TpDmCell::Text(value.to_string().into_bytes()) +} + +fn convert_lob( + client: &mut Client, + locator: dameng_types::LobLocator, +) -> Result { + let data = client.read_lob(&locator)?; + let cell = if locator.is_clob { + TpDmCell::Text(decode_from_server(client.server_encoding, &data).into_bytes()) + } else { + TpDmCell::Bytes(data) + }; + client.free_lob(&locator)?; + Ok(cell) +} + +fn convert_cell( + client: &mut Client, + row: &dameng::Row, + columns: &[dameng::row::Column], + index: usize, +) -> Result { + let Some(value) = row.get(index, columns) else { + return Ok(TpDmCell::Null); + }; + match value { + DmValue::Null => Ok(TpDmCell::Null), + DmValue::Boolean(value) => Ok(text_cell(if value { "1" } else { "0" })), + DmValue::TinyInt(value) => Ok(text_cell(value)), + DmValue::SmallInt(value) => Ok(text_cell(value)), + DmValue::Int(value) => Ok(text_cell(value)), + DmValue::BigInt(value) => Ok(text_cell(value)), + DmValue::Float(value) => Ok(text_cell(value)), + DmValue::Double(value) => Ok(text_cell(value)), + DmValue::Text(value) => Ok(TpDmCell::Text(value.into_bytes())), + DmValue::Bytea(value) => Ok(TpDmCell::Bytes(value)), + DmValue::Decimal(value) => Ok(text_cell(value)), + DmValue::Date(value) => Ok(text_cell(value)), + DmValue::Time(value) => Ok(text_cell(value)), + DmValue::Timestamp(value) => Ok(text_cell(value)), + DmValue::LobLocator(locator) => convert_lob(client, locator), + } +} + +fn query_result( + client: &mut Client, + sql: &str, + row_cap: usize, +) -> Result { + let started = Instant::now(); + let mut result = client.query(sql)?; + let fetch_limit = if row_cap > 0 { + row_cap.saturating_add(1) + } else { + usize::MAX + }; + while result.rows.len() < usize::try_from(result.total_row_count).unwrap_or(usize::MAX) + && result.rows.len() < fetch_limit + { + let previous_count = result.rows.len(); + client.fetch_more(&mut result, previous_count, 65_536)?; + if result.rows.len() == previous_count { + break; + } + } + let columns = result + .columns + .iter() + .map(|column| TpDmColumn { + name: column.name.as_bytes().to_vec(), + type_name: column.type_name.as_bytes().to_vec(), + }) + .collect(); + let is_truncated = row_cap > 0 + && (result.rows.len() > row_cap + || result.total_row_count > u64::try_from(row_cap).unwrap_or(u64::MAX)); + let row_count = if row_cap > 0 { + result.rows.len().min(row_cap) + } else { + result.rows.len() + }; + let mut rows = Vec::with_capacity(row_count); + for row in result.rows.iter().take(row_count) { + let mut cells = Vec::with_capacity(result.columns.len()); + for index in 0..result.columns.len() { + cells.push(convert_cell(client, row, &result.columns, index)?); + } + rows.push(cells); + } + Ok(TpDmResult { + columns, + rows, + rows_affected: result.total_row_count, + execution_time_seconds: started.elapsed().as_secs_f64(), + is_truncated, + }) +} + +fn execute_result(client: &mut Client, sql: &str) -> Result { + let started = Instant::now(); + let rows_affected = client.execute(sql)?; + Ok(TpDmResult { + columns: Vec::new(), + rows: Vec::new(), + rows_affected, + execution_time_seconds: started.elapsed().as_secs_f64(), + is_truncated: false, + }) +} + +fn connection_client(connection: &mut TpDmConnection) -> Result { + connection + .client + .take() + .ok_or_else(|| "Dameng connection is closed".to_string()) +} + +fn restore_client(connection: &mut TpDmConnection, client: Client) { + connection.client = Some(client); +} + +fn is_recoverable(error: &dameng::Error) -> bool { + !matches!( + error, + dameng::Error::Protocol(_) + | dameng::Error::Io(_) + | dameng::Error::ConnectionFailed(_) + | dameng::Error::AuthFailed(_) + | dameng::Error::NotConnected + | dameng::Error::Timeout(_) + ) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_connect( + host: *const u8, + host_length: usize, + port: u16, + username: *const u8, + username_length: usize, + password: *const u8, + password_length: usize, + error_out: *mut *mut TpDmError, +) -> *mut TpDmConnection { + clear_error(error_out); + let operation = catch_unwind(AssertUnwindSafe(|| -> Result { + if port == 0 { + return Err("port must be greater than zero".to_string()); + } + let host = required_string(host, host_length, MAX_HOST_BYTES, "host")?; + let username = + required_string(username, username_length, MAX_CREDENTIAL_BYTES, "username")?; + let password = password_string(password, password_length)?; + let mut client = Client::new(&host, port); + client + .connect(&username, &password) + .map_err(|error| error.to_string())?; + detect_server_encoding(&mut client)?; + Ok(TpDmConnection { + client: Some(client), + }) + })); + match operation { + Ok(Ok(connection)) => Box::into_raw(Box::new(connection)), + Ok(Err(message)) => { + set_error(error_out, message); + ptr::null_mut() + } + Err(payload) => { + set_error(error_out, panic_message(payload)); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_disconnect(connection: *mut TpDmConnection) { + if connection.is_null() { + return; + } + let _ = catch_unwind(AssertUnwindSafe(|| { + let mut connection = Box::from_raw(connection); + if let Some(mut client) = connection.client.take() { + let _ = client.close(); + } + })); +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_execute( + connection: *mut TpDmConnection, + sql: *const u8, + sql_length: usize, + expects_rows: bool, + row_cap: usize, + error_out: *mut *mut TpDmError, +) -> *mut TpDmResult { + clear_error(error_out); + if connection.is_null() { + set_error(error_out, "Dameng connection is missing"); + return ptr::null_mut(); + } + let sql = match optional_sql(sql, sql_length) { + Ok(sql) => sql, + Err(message) => { + set_error(error_out, message); + return ptr::null_mut(); + } + }; + let connection = &mut *connection; + let mut client = match connection_client(connection) { + Ok(client) => client, + Err(message) => { + set_error(error_out, message); + return ptr::null_mut(); + } + }; + let operation = catch_unwind(AssertUnwindSafe(|| { + if expects_rows { + query_result(&mut client, &sql, row_cap) + } else { + execute_result(&mut client, &sql) + } + })); + match operation { + Ok(Ok(result)) => { + restore_client(connection, client); + Box::into_raw(Box::new(result)) + } + Ok(Err(error)) => { + if is_recoverable(&error) { + restore_client(connection, client); + } else { + let _ = client.close(); + } + set_error(error_out, error.to_string()); + ptr::null_mut() + } + Err(payload) => { + set_error(error_out, panic_message(payload)); + ptr::null_mut() + } + } +} + +unsafe fn transaction_operation( + connection: *mut TpDmConnection, + error_out: *mut *mut TpDmError, + operation: impl FnOnce(&mut Client) -> Result<(), dameng::Error>, +) -> bool { + clear_error(error_out); + if connection.is_null() { + set_error(error_out, "Dameng connection is missing"); + return false; + } + let connection = &mut *connection; + let mut client = match connection_client(connection) { + Ok(client) => client, + Err(message) => { + set_error(error_out, message); + return false; + } + }; + let result = catch_unwind(AssertUnwindSafe(|| operation(&mut client))); + match result { + Ok(Ok(())) => { + restore_client(connection, client); + true + } + Ok(Err(error)) => { + if is_recoverable(&error) { + restore_client(connection, client); + } else { + let _ = client.close(); + } + set_error(error_out, error.to_string()); + false + } + Err(payload) => { + set_error(error_out, panic_message(payload)); + false + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_begin( + connection: *mut TpDmConnection, + error_out: *mut *mut TpDmError, +) -> bool { + transaction_operation(connection, error_out, Client::begin) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_commit( + connection: *mut TpDmConnection, + error_out: *mut *mut TpDmError, +) -> bool { + transaction_operation(connection, error_out, Client::commit) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_rollback( + connection: *mut TpDmConnection, + error_out: *mut *mut TpDmError, +) -> bool { + transaction_operation(connection, error_out, Client::rollback) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_ping( + connection: *mut TpDmConnection, + error_out: *mut *mut TpDmError, +) -> bool { + transaction_operation(connection, error_out, |client| client.ready()) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_free(result: *mut TpDmResult) { + if !result.is_null() { + drop(Box::from_raw(result)); + } +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_error_free(error: *mut TpDmError) { + if !error.is_null() { + drop(Box::from_raw(error)); + } +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_error_message( + error: *const TpDmError, + length_out: *mut usize, +) -> *const u8 { + if error.is_null() { + return ptr::null(); + } + let message = &(*error).message; + if !length_out.is_null() { + *length_out = message.len(); + } + message.as_ptr() +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_column_count(result: *const TpDmResult) -> usize { + result + .as_ref() + .map(|result| result.columns.len()) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_row_count(result: *const TpDmResult) -> usize { + result.as_ref().map(|result| result.rows.len()).unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_rows_affected(result: *const TpDmResult) -> u64 { + result + .as_ref() + .map(|result| result.rows_affected) + .unwrap_or(0) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_execution_time(result: *const TpDmResult) -> f64 { + result + .as_ref() + .map(|result| result.execution_time_seconds) + .unwrap_or(0.0) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_is_truncated(result: *const TpDmResult) -> bool { + result + .as_ref() + .map(|result| result.is_truncated) + .unwrap_or(false) +} + +unsafe fn column_bytes( + result: *const TpDmResult, + column_index: usize, + length_out: *mut usize, + value: impl FnOnce(&TpDmColumn) -> &[u8], +) -> *const u8 { + let Some(column) = result + .as_ref() + .and_then(|result| result.columns.get(column_index)) + else { + return ptr::null(); + }; + let bytes = value(column); + if !length_out.is_null() { + *length_out = bytes.len(); + } + bytes.as_ptr() +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_column_name( + result: *const TpDmResult, + column_index: usize, + length_out: *mut usize, +) -> *const u8 { + column_bytes(result, column_index, length_out, |column| &column.name) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_column_type( + result: *const TpDmResult, + column_index: usize, + length_out: *mut usize, +) -> *const u8 { + column_bytes(result, column_index, length_out, |column| &column.type_name) +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_cell_kind( + result: *const TpDmResult, + row_index: usize, + column_index: usize, +) -> i32 { + let Some(cell) = result + .as_ref() + .and_then(|result| result.rows.get(row_index)) + .and_then(|row| row.get(column_index)) + else { + return -1; + }; + match cell { + TpDmCell::Null => 0, + TpDmCell::Text(_) => 1, + TpDmCell::Bytes(_) => 2, + } +} + +#[no_mangle] +pub unsafe extern "C" fn tp_dm_result_cell_bytes( + result: *const TpDmResult, + row_index: usize, + column_index: usize, + length_out: *mut usize, +) -> *const u8 { + let Some(cell) = result + .as_ref() + .and_then(|result| result.rows.get(row_index)) + .and_then(|row| row.get(column_index)) + else { + return ptr::null(); + }; + let bytes = match cell { + TpDmCell::Null => return ptr::null(), + TpDmCell::Text(bytes) | TpDmCell::Bytes(bytes) => bytes, + }; + if !length_out.is_null() { + *length_out = bytes.len(); + } + bytes.as_ptr() +} + +#[cfg(test)] +mod tests { + use std::env; + + use super::*; + + unsafe fn error_text(error: *mut TpDmError) -> String { + let mut length = 0; + let bytes = tp_dm_error_message(error, &mut length); + let text = String::from_utf8_lossy(slice::from_raw_parts(bytes, length)).into_owned(); + tp_dm_error_free(error); + text + } + + unsafe fn execute( + connection: *mut TpDmConnection, + sql: &str, + expects_rows: bool, + ) -> *mut TpDmResult { + let mut error = ptr::null_mut(); + let result = tp_dm_execute( + connection, + sql.as_ptr(), + sql.len(), + expects_rows, + 0, + &mut error, + ); + assert!(error.is_null(), "{}", error_text(error)); + assert!(!result.is_null()); + result + } + + #[test] + fn rejects_zero_port_before_connecting() { + unsafe { + let mut error = ptr::null_mut(); + let connection = tp_dm_connect( + b"localhost".as_ptr(), + 9, + 0, + b"user".as_ptr(), + 4, + b"password".as_ptr(), + 8, + &mut error, + ); + assert!(connection.is_null()); + assert_eq!(error_text(error), "port must be greater than zero"); + } + } + + #[test] + #[ignore = "requires DM8 in OrbStack"] + fn orbstack_connection_query_and_transaction() { + unsafe { + let host = env::var("DM_HOST").expect("DM_HOST is required"); + let port = env::var("DM_PORT") + .expect("DM_PORT is required") + .parse() + .expect("DM_PORT must be a UInt16"); + let username = env::var("DM_USER").expect("DM_USER is required"); + let password = env::var("DM_PASS").expect("DM_PASS is required"); + let mut error = ptr::null_mut(); + let connection = tp_dm_connect( + host.as_ptr(), + host.len(), + port, + username.as_ptr(), + username.len(), + password.as_ptr(), + password.len(), + &mut error, + ); + assert!(error.is_null(), "{}", error_text(error)); + assert!(!connection.is_null()); + + let drop_result = execute( + connection, + "DROP TABLE IF EXISTS TABLEPRO_BRIDGE_TEST", + false, + ); + tp_dm_result_free(drop_result); + let create_result = execute( + connection, + "CREATE TABLE TABLEPRO_BRIDGE_TEST (\ + ID INT PRIMARY KEY, \ + NAME VARCHAR(100), \ + STATUS VARCHAR(20), \ + TOTAL DECIMAL(12, 2), \ + CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP\ + )", + false, + ); + tp_dm_result_free(create_result); + let insert_result = execute( + connection, + "INSERT INTO TABLEPRO_BRIDGE_TEST (ID, NAME, STATUS, TOTAL) \ + VALUES (1, 'TablePro 达梦', 'READY', 42.50)", + false, + ); + assert_eq!(tp_dm_result_rows_affected(insert_result), 1); + tp_dm_result_free(insert_result); + + let select_result = execute( + connection, + "SELECT NAME FROM TABLEPRO_BRIDGE_TEST WHERE ID = 1", + true, + ); + assert_eq!(tp_dm_result_row_count(select_result), 1); + let mut length = 0; + let bytes = tp_dm_result_cell_bytes(select_result, 0, 0, &mut length); + assert_eq!( + slice::from_raw_parts(bytes, length), + "TablePro 达梦".as_bytes() + ); + tp_dm_result_free(select_result); + + let browse_result = execute( + connection, + "SELECT ID, NAME, STATUS, TOTAL, CREATED_AT \ + FROM TABLEPRO_BRIDGE_TEST ORDER BY 1 \ + OFFSET 0 ROWS FETCH NEXT 1000 ROWS ONLY", + true, + ); + assert_eq!(tp_dm_result_column_count(browse_result), 5); + assert_eq!(tp_dm_result_row_count(browse_result), 1); + tp_dm_result_free(browse_result); + + let empty_result = execute( + connection, + "SELECT NAME FROM TABLEPRO_BRIDGE_TEST WHERE ID = -1", + true, + ); + assert_eq!(tp_dm_result_column_count(empty_result), 1); + assert_eq!(tp_dm_result_row_count(empty_result), 0); + tp_dm_result_free(empty_result); + + assert!(tp_dm_begin(connection, &mut error)); + let transaction_insert = execute( + connection, + "INSERT INTO TABLEPRO_BRIDGE_TEST (ID, NAME) VALUES (2, 'rollback')", + false, + ); + tp_dm_result_free(transaction_insert); + assert!(tp_dm_rollback(connection, &mut error)); + let count_result = execute( + connection, + "SELECT COUNT(*) FROM TABLEPRO_BRIDGE_TEST", + true, + ); + let bytes = tp_dm_result_cell_bytes(count_result, 0, 0, &mut length); + assert_eq!(slice::from_raw_parts(bytes, length), b"1"); + tp_dm_result_free(count_result); + + let cleanup_result = execute(connection, "DROP TABLE TABLEPRO_BRIDGE_TEST", false); + tp_dm_result_free(cleanup_result); + tp_dm_disconnect(connection); + } + } +} diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift index 2ddde73cb..632771ef1 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift @@ -18,6 +18,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let clickhouse = DatabaseType(rawValue: "ClickHouse") public static let mssql = DatabaseType(rawValue: "SQL Server") public static let oracle = DatabaseType(rawValue: "Oracle") + public static let dameng = DatabaseType(rawValue: "Dameng") public static let duckdb = DatabaseType(rawValue: "DuckDB") public static let cassandra = DatabaseType(rawValue: "Cassandra") public static let redshift = DatabaseType(rawValue: "Redshift") @@ -37,7 +38,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, - .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, + .clickhouse, .mssql, .oracle, .dameng, .duckdb, .cassandra, .redshift, .etcd, .cloudflareD1, .dynamodb, .bigquery, .snowflake, .libsql, .beancount, .surrealdb, .teradata, .trino ] @@ -55,6 +56,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { case .clickhouse: return "clickhouse-icon" case .mssql: return "mssql-icon" case .oracle: return "oracle-icon" + case .dameng: return "cylinder" case .duckdb: return "duckdb-icon" case .cassandra: return "cassandra-icon" case .etcd: return "etcd-icon" diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index 97e55b829..88ccf0f9d 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -13,6 +13,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.redis.rawValue == "Redis") #expect(DatabaseType.mongodb.rawValue == "MongoDB") #expect(DatabaseType.mssql.rawValue == "SQL Server") + #expect(DatabaseType.dameng.rawValue == "Dameng") #expect(DatabaseType.cloudflareD1.rawValue == "Cloudflare D1") #expect(DatabaseType.bigquery.rawValue == "BigQuery") #expect(DatabaseType.snowflake.rawValue == "Snowflake") @@ -53,7 +54,7 @@ struct DatabaseTypeTests { @Test("allKnownTypes contains all expected types") func allKnownTypesComplete() { - #expect(DatabaseType.allKnownTypes.count == 22) + #expect(DatabaseType.allKnownTypes.count == 23) #expect(DatabaseType.allKnownTypes.contains(.mysql)) #expect(DatabaseType.allKnownTypes.contains(.bigquery)) #expect(DatabaseType.allKnownTypes.contains(.snowflake)) @@ -62,6 +63,7 @@ struct DatabaseTypeTests { #expect(DatabaseType.allKnownTypes.contains(.surrealdb)) #expect(DatabaseType.allKnownTypes.contains(.teradata)) #expect(DatabaseType.allKnownTypes.contains(.trino)) + #expect(DatabaseType.allKnownTypes.contains(.dameng)) } @Test("Hashable conformance") diff --git a/Plugins/DamengDriverPlugin/CDameng/CDameng.h b/Plugins/DamengDriverPlugin/CDameng/CDameng.h new file mode 100644 index 000000000..28f9d6481 --- /dev/null +++ b/Plugins/DamengDriverPlugin/CDameng/CDameng.h @@ -0,0 +1,80 @@ +#ifndef CDameng_h +#define CDameng_h + +#include +#include +#include + +typedef struct TpDmConnection TpDmConnection; +typedef struct TpDmError TpDmError; +typedef struct TpDmResult TpDmResult; + +/* + * Ownership and lifetime rules: + * - Input byte pointers must remain valid for their accompanying length until + * the function returns. + * - Connection handles are exclusively owned by the caller, must not be used + * concurrently, and must be released exactly once with tp_dm_disconnect. + * - Result and error handles must be released exactly once with their matching + * free function. Byte pointers borrowed from them remain valid only until the + * owning handle is released and must never be freed directly. + * - Non-NULL output pointers must point to writable storage. On failure, + * error_out receives an owned error handle when diagnostic text is available. + * - Passing a dangling handle or an invalid pointer is undefined behavior. + */ +TpDmConnection *tp_dm_connect( + const uint8_t *host, + size_t host_length, + uint16_t port, + const uint8_t *username, + size_t username_length, + const uint8_t *password, + size_t password_length, + TpDmError **error_out +); +void tp_dm_disconnect(TpDmConnection *connection); +TpDmResult *tp_dm_execute( + TpDmConnection *connection, + const uint8_t *sql, + size_t sql_length, + bool expects_rows, + size_t row_cap, + TpDmError **error_out +); +bool tp_dm_begin(TpDmConnection *connection, TpDmError **error_out); +bool tp_dm_commit(TpDmConnection *connection, TpDmError **error_out); +bool tp_dm_rollback(TpDmConnection *connection, TpDmError **error_out); +bool tp_dm_ping(TpDmConnection *connection, TpDmError **error_out); + +void tp_dm_result_free(TpDmResult *result); +size_t tp_dm_result_column_count(const TpDmResult *result); +size_t tp_dm_result_row_count(const TpDmResult *result); +uint64_t tp_dm_result_rows_affected(const TpDmResult *result); +double tp_dm_result_execution_time(const TpDmResult *result); +bool tp_dm_result_is_truncated(const TpDmResult *result); +const uint8_t *tp_dm_result_column_name( + const TpDmResult *result, + size_t column_index, + size_t *length_out +); +const uint8_t *tp_dm_result_column_type( + const TpDmResult *result, + size_t column_index, + size_t *length_out +); +int32_t tp_dm_result_cell_kind( + const TpDmResult *result, + size_t row_index, + size_t column_index +); +const uint8_t *tp_dm_result_cell_bytes( + const TpDmResult *result, + size_t row_index, + size_t column_index, + size_t *length_out +); + +void tp_dm_error_free(TpDmError *error); +const uint8_t *tp_dm_error_message(const TpDmError *error, size_t *length_out); + +#endif /* CDameng_h */ diff --git a/Plugins/DamengDriverPlugin/CDameng/module.modulemap b/Plugins/DamengDriverPlugin/CDameng/module.modulemap new file mode 100644 index 000000000..5b2fdd041 --- /dev/null +++ b/Plugins/DamengDriverPlugin/CDameng/module.modulemap @@ -0,0 +1,4 @@ +module CDameng { + umbrella header "CDameng.h" + export * +} diff --git a/Plugins/DamengDriverPlugin/DamengConnection.swift b/Plugins/DamengDriverPlugin/DamengConnection.swift new file mode 100644 index 000000000..8538262b6 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengConnection.swift @@ -0,0 +1,230 @@ +import CDameng +import Foundation +import TableProPluginKit + +struct DamengRawResult: Sendable { + let columns: [String] + let columnTypeNames: [String] + let rows: [[PluginCellValue]] + let rowsAffected: Int + let executionTime: TimeInterval + let isTruncated: Bool +} + +final class DamengConnection: @unchecked Sendable { + private enum TransactionCommand: Sendable { + case begin + case commit + case rollback + } + + private let queue = DispatchQueue(label: "com.TablePro.dameng.connection") + private var rawConnection: OpaquePointer? + + deinit { + if let rawConnection { + tp_dm_disconnect(rawConnection) + } + } + + func connect(host: String, port: Int, username: String, password: String) async throws { + guard let port = UInt16(exactly: port), port > 0 else { + throw DamengError(message: String(localized: "The Dameng port must be between 1 and 65535.")) + } + try await run { + guard self.rawConnection == nil else { return } + var rawError: OpaquePointer? + let connection = host.withUTF8Bytes { hostBytes in + username.withUTF8Bytes { usernameBytes in + password.withUTF8Bytes { passwordBytes in + tp_dm_connect( + hostBytes.baseAddress, + hostBytes.count, + port, + usernameBytes.baseAddress, + usernameBytes.count, + passwordBytes.baseAddress, + passwordBytes.count, + &rawError + ) + } + } + } + guard let connection else { + throw Self.error(from: rawError) + } + self.rawConnection = connection + } + } + + func disconnect() { + queue.sync { + guard let rawConnection else { return } + tp_dm_disconnect(rawConnection) + self.rawConnection = nil + } + } + + func ping() async throws { + try await run { + let connection = try self.connectedPointer() + var rawError: OpaquePointer? + guard tp_dm_ping(connection, &rawError) else { + throw Self.error(from: rawError) + } + } + } + + func execute(_ query: String, expectsRows: Bool, rowCap: Int?) async throws -> DamengRawResult { + try await run { + let connection = try self.connectedPointer() + var rawError: OpaquePointer? + let rawResult = query.withUTF8Bytes { bytes in + tp_dm_execute( + connection, + bytes.baseAddress, + bytes.count, + expectsRows, + max(rowCap ?? 0, 0), + &rawError + ) + } + guard let rawResult else { + throw Self.error(from: rawError) + } + defer { tp_dm_result_free(rawResult) } + return try Self.decode(rawResult) + } + } + + func beginTransaction() async throws { + try await transactionOperation(.begin) + } + + func commitTransaction() async throws { + try await transactionOperation(.commit) + } + + func rollbackTransaction() async throws { + try await transactionOperation(.rollback) + } + + private func transactionOperation(_ command: TransactionCommand) async throws { + try await run { + let connection = try self.connectedPointer() + var rawError: OpaquePointer? + let succeeded = switch command { + case .begin: + tp_dm_begin(connection, &rawError) + case .commit: + tp_dm_commit(connection, &rawError) + case .rollback: + tp_dm_rollback(connection, &rawError) + } + guard succeeded else { + throw Self.error(from: rawError) + } + } + } + + private func connectedPointer() throws -> OpaquePointer { + guard let rawConnection else { + throw DamengError(message: String(localized: "The Dameng connection is closed.")) + } + return rawConnection + } + + private func run(_ operation: @escaping @Sendable () throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try operation()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func decode(_ result: OpaquePointer) throws -> DamengRawResult { + let columnCount = tp_dm_result_column_count(result) + let rowCount = tp_dm_result_row_count(result) + let columns = try (0.. UInt64(Int.max) ? Int.max : Int(affected), + executionTime: tp_dm_result_execution_time(result), + isTruncated: tp_dm_result_is_truncated(result) + ) + } + + private static func string( + result: OpaquePointer, + column: Int, + reader: (OpaquePointer?, Int, UnsafeMutablePointer?) -> UnsafePointer? + ) throws -> String { + var length = 0 + guard let bytes = reader(result, column, &length), + let value = String(bytes: UnsafeBufferPointer(start: bytes, count: length), encoding: .utf8) else { + throw DamengError(message: String(localized: "Dameng returned invalid UTF-8 metadata.")) + } + return value + } + + private static func cell(result: OpaquePointer, row: Int, column: Int) throws -> PluginCellValue { + switch tp_dm_result_cell_kind(result, row, column) { + case 0: + return .null + case 1: + let data = try cellData(result: result, row: row, column: column) + guard let value = String(data: data, encoding: .utf8) else { + throw DamengError(message: String(localized: "Dameng returned invalid UTF-8 text.")) + } + return .text(value) + case 2: + return .bytes(try cellData(result: result, row: row, column: column)) + default: + throw DamengError(message: String(localized: "Dameng returned an invalid result cell.")) + } + } + + private static func cellData(result: OpaquePointer, row: Int, column: Int) throws -> Data { + var length = 0 + guard let bytes = tp_dm_result_cell_bytes(result, row, column, &length) else { + throw DamengError(message: String(localized: "Dameng returned an invalid result value.")) + } + return Data(bytes: bytes, count: length) + } + + private static func error(from pointer: OpaquePointer?) -> DamengError { + guard let pointer else { + return DamengError(message: String(localized: "The Dameng operation failed.")) + } + defer { tp_dm_error_free(pointer) } + var length = 0 + guard let bytes = tp_dm_error_message(pointer, &length), + let message = String(bytes: UnsafeBufferPointer(start: bytes, count: length), encoding: .utf8) else { + return DamengError(message: String(localized: "The Dameng operation failed.")) + } + return DamengError(message: message) + } +} + +private extension String { + func withUTF8Bytes(_ body: (UnsafeBufferPointer) throws -> T) rethrows -> T { + try Array(utf8).withUnsafeBufferPointer(body) + } +} diff --git a/Plugins/DamengDriverPlugin/DamengError.swift b/Plugins/DamengDriverPlugin/DamengError.swift new file mode 100644 index 000000000..b7ccdc245 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengError.swift @@ -0,0 +1,8 @@ +import Foundation +import TableProPluginKit + +struct DamengError: Error, PluginDriverError, Sendable { + let message: String + + var pluginErrorMessage: String { message } +} diff --git a/Plugins/DamengDriverPlugin/DamengParameterBinder.swift b/Plugins/DamengDriverPlugin/DamengParameterBinder.swift new file mode 100644 index 000000000..4f286d8e2 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengParameterBinder.swift @@ -0,0 +1,146 @@ +import Foundation +import TableProPluginKit + +enum DamengParameterBindingError: Error, Equatable { + case embeddedNull + case insufficientParameters + case unusedParameters +} +enum DamengParameterBinder { + private enum State: Equatable { + case code + case singleQuote + case doubleQuote + case alternativeQuote(UInt8) + case lineComment + case blockComment(Int) + } + + static func bind(query: String, parameters: [PluginCellValue]) throws -> String { + let input = Array(query.utf8) + var output: [UInt8] = [] + output.reserveCapacity(input.count + parameters.count * 8) + var state = State.code + var parameterIndex = 0 + var index = 0 + + while index < input.count { + let byte = input[index] + let next = index + 1 < input.count ? input[index + 1] : nil + + switch state { + case .code: + if byte == 0x2D, next == 0x2D { + output.append(contentsOf: [byte, 0x2D]) + state = .lineComment + index += 2 + } else if byte == 0x2F, next == 0x2A { + output.append(contentsOf: [byte, 0x2A]) + state = .blockComment(1) + index += 2 + } else if byte == 0x27 { + output.append(byte) + state = .singleQuote + index += 1 + } else if byte == 0x22 { + output.append(byte) + state = .doubleQuote + index += 1 + } else if isAlternativeQuoteStart(input, at: index) { + let opener = input[index + 2] + output.append(contentsOf: input[index...index + 2]) + state = .alternativeQuote(alternativeQuoteCloser(opener)) + index += 3 + } else if byte == 0x3F { + guard parameterIndex < parameters.count else { + throw DamengParameterBindingError.insufficientParameters + } + output.append(contentsOf: try literal(for: parameters[parameterIndex]).utf8) + parameterIndex += 1 + index += 1 + } else { + output.append(byte) + index += 1 + } + case .singleQuote: + output.append(byte) + if byte == 0x27, next == 0x27 { + output.append(0x27) + index += 2 + } else { + if byte == 0x27 { state = .code } + index += 1 + } + case .doubleQuote: + output.append(byte) + if byte == 0x22, next == 0x22 { + output.append(0x22) + index += 2 + } else { + if byte == 0x22 { state = .code } + index += 1 + } + case .alternativeQuote(let closer): + output.append(byte) + if byte == closer, next == 0x27 { + output.append(0x27) + state = .code + index += 2 + } else { + index += 1 + } + case .lineComment: + output.append(byte) + if byte == 0x0A || byte == 0x0D { state = .code } + index += 1 + case .blockComment(let depth): + if byte == 0x2F, next == 0x2A { + output.append(contentsOf: [byte, 0x2A]) + state = .blockComment(depth + 1) + index += 2 + } else if byte == 0x2A, next == 0x2F { + output.append(contentsOf: [byte, 0x2F]) + state = depth == 1 ? .code : .blockComment(depth - 1) + index += 2 + } else { + output.append(byte) + index += 1 + } + } + } + + guard parameterIndex == parameters.count else { + throw DamengParameterBindingError.unusedParameters + } + return String(decoding: output, as: UTF8.self) + } + + private static func literal(for value: PluginCellValue) throws -> String { + switch value { + case .null: + return "NULL" + case .text(let text): + guard !text.contains("\0") else { + throw DamengParameterBindingError.embeddedNull + } + return "'\(text.replacingOccurrences(of: "'", with: "''"))'" + case .bytes(let data): + return "HEXTORAW('\(data.map { String(format: "%02X", $0) }.joined())')" + } + } + + private static func isAlternativeQuoteStart(_ bytes: [UInt8], at index: Int) -> Bool { + guard index + 2 < bytes.count else { return false } + return (bytes[index] == 0x51 || bytes[index] == 0x71) && bytes[index + 1] == 0x27 + } + + private static func alternativeQuoteCloser(_ opener: UInt8) -> UInt8 { + switch opener { + case 0x5B: return 0x5D + case 0x28: return 0x29 + case 0x7B: return 0x7D + case 0x3C: return 0x3E + default: return opener + } + } +} diff --git a/Plugins/DamengDriverPlugin/DamengPlugin.swift b/Plugins/DamengDriverPlugin/DamengPlugin.swift new file mode 100644 index 000000000..b1142b981 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengPlugin.swift @@ -0,0 +1,256 @@ +import Foundation +import TableProPluginKit + +final class DamengPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Dameng Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Dameng DM8 support via a native wire driver" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Dameng" + static let databaseDisplayName = "Dameng DM8" + static let iconName = "cylinder" + static let defaultPort = 5_236 + static let isDownloadable = true + static let urlSchemes = ["dm"] + static let brandColorHex = "#C60018" + static let supportsSSL = false + static let supportsDatabaseSwitching = false + static let supportsSchemaSwitching = true + static let defaultSchemaName = "" + static let containerEntityName = "Schema" + static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] + static let explainVariants = [ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN")] + static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema + static let pathFieldRole: PathFieldRole = .database + static let systemSchemaNames = ["SYS", "SYSDBA", "SYSAUDITOR", "SYSSSO", "CTISYS"] + static let supportsCascadeDrop = true + static let supportsDropSchema = true + static let supportsForeignKeyDisable = false + static let supportsRenameColumn = true + static let columnTypesByCategory: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "INTEGER", "BIGINT"], + "Float": ["REAL", "FLOAT", "DOUBLE", "DEC", "DECIMAL", "NUMERIC", "NUMBER"], + "String": ["CHAR", "CHARACTER", "VARCHAR", "VARCHAR2", "TEXT", "CLOB"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BINARY", "VARBINARY", "BLOB", "IMAGE"], + "Boolean": ["BIT", "BOOLEAN"], + "Other": ["ROWID", "INTERVAL"] + ] + static let statementCompletions = [ + CompletionEntry(label: "SELECT", insertText: "SELECT"), + CompletionEntry(label: "SELECT DISTINCT", insertText: "SELECT DISTINCT"), + CompletionEntry(label: "INSERT INTO", insertText: "INSERT INTO"), + CompletionEntry(label: "UPDATE", insertText: "UPDATE"), + CompletionEntry(label: "DELETE FROM", insertText: "DELETE FROM"), + CompletionEntry(label: "MERGE INTO", insertText: "MERGE INTO"), + CompletionEntry(label: "CREATE TABLE", insertText: "CREATE TABLE"), + CompletionEntry(label: "CREATE OR REPLACE VIEW", insertText: "CREATE OR REPLACE VIEW"), + CompletionEntry(label: "CREATE SCHEMA", insertText: "CREATE SCHEMA"), + CompletionEntry(label: "ALTER TABLE", insertText: "ALTER TABLE"), + CompletionEntry(label: "DROP TABLE", insertText: "DROP TABLE"), + CompletionEntry(label: "DROP SCHEMA", insertText: "DROP SCHEMA"), + CompletionEntry(label: "SET SCHEMA", insertText: "SET SCHEMA"), + CompletionEntry(label: "EXPLAIN", insertText: "EXPLAIN"), + CompletionEntry(label: "WHERE", insertText: "WHERE"), + CompletionEntry(label: "GROUP BY", insertText: "GROUP BY"), + CompletionEntry(label: "ORDER BY", insertText: "ORDER BY"), + CompletionEntry(label: "FETCH FIRST", insertText: "FETCH FIRST"), + CompletionEntry(label: "JOIN", insertText: "JOIN"), + CompletionEntry(label: "LEFT JOIN", insertText: "LEFT JOIN"), + CompletionEntry(label: "UNION ALL", insertText: "UNION ALL"), + CompletionEntry(label: "WITH", insertText: "WITH"), + CompletionEntry(label: "CONNECT BY", insertText: "CONNECT BY"), + CompletionEntry(label: "START WITH", insertText: "START WITH"), + CompletionEntry(label: "PARTITION BY", insertText: "PARTITION BY") + ] + static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ORDER", "BY", "GROUP", + "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", "INSERT", "INTO", "VALUES", + "UPDATE", "SET", "DELETE", "MERGE", "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", + "SCHEMA", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", "ADD", "MODIFY", + "COLUMN", "RENAME", "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "IDENTITY", "SEQUENCE", "SYNONYM", "GRANT", "REVOKE", "TRIGGER", "PROCEDURE", "CASE", "WHEN", + "THEN", "ELSE", "END", "UNION", "INTERSECT", "MINUS", "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", + "SAVEPOINT", "EXECUTE", "IMMEDIATE", "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", + "CONNECT", "LEVEL", "START", "WITH", "PRIOR", "ROWNUM", "ROWID", "DUAL" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "LISTAGG", "CONCAT", "SUBSTR", "INSTR", "LENGTH", "LOWER", + "UPPER", "TRIM", "LTRIM", "RTRIM", "REPLACE", "LPAD", "RPAD", "SYSDATE", "CURRENT_DATE", + "CURRENT_TIMESTAMP", "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "EXTRACT", "TO_DATE", "TO_CHAR", + "TO_NUMBER", "TO_TIMESTAMP", "TRUNC", "ROUND", "CEIL", "FLOOR", "ABS", "POWER", "SQRT", "MOD", + "NVL", "NVL2", "COALESCE", "NULLIF", "GREATEST", "LEAST", "CAST", "USER" + ], + dataTypes: Set(columnTypesByCategory.values.flatMap { $0 }), + tableOptions: ["TABLESPACE", "STORAGE", "PCTFREE", "INITRANS"], + regexSyntax: .regexpLike, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .offsetFetch, + offsetFetchOrderBy: "ORDER BY 1", + autoLimitStyle: .fetchFirst, + caseSensitivityStyle: .caseFoldFunction + ) + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + DamengPluginDriver(config: config) + } +} + +final class DamengPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + let config: DriverConnectionConfig + private let connection = DamengConnection() + private var activeSchema: String? + private var detectedServerVersion: String? + + var capabilities: PluginCapabilities { + [.parameterizedQueries, .transactions, .alterTableDDL, .multiSchema] + } + + var supportsSchemas: Bool { true } + var supportsTransactions: Bool { true } + var currentSchema: String? { activeSchema } + var serverVersion: String? { detectedServerVersion } + + init(config: DriverConnectionConfig) { + self.config = config + } + + func connect() async throws { + try await connection.connect( + host: config.host, + port: config.port, + username: config.username, + password: config.password + ) + do { + if !config.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try await switchSchema(to: config.database) + } else { + activeSchema = try await scalarText("SELECT SF_GET_SCHEMA_NAME_BY_ID(CURRENT_SCHID)") + } + detectedServerVersion = try await scalarText("SELECT BANNER FROM V$VERSION WHERE ROWNUM = 1") + } catch { + disconnect() + throw error + } + } + + func disconnect() { + connection.disconnect() + activeSchema = nil + detectedServerVersion = nil + } + + func ping() async throws { + try await connection.ping() + } + + func execute(query: String) async throws -> PluginQueryResult { + try await executeBound(query: query, parameters: [], rowCap: nil) + } + + func executeUserQuery( + query: String, + rowCap: Int?, + parameters: [PluginCellValue]? + ) async throws -> PluginQueryResult { + try await executeBound(query: query, parameters: parameters ?? [], rowCap: rowCap) + } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + try await executeBound(query: query, parameters: parameters, rowCap: nil) + } + + func beginTransaction() async throws { + try await connection.beginTransaction() + } + + func commitTransaction() async throws { + try await connection.commitTransaction() + } + + func rollbackTransaction() async throws { + try await connection.rollbackTransaction() + } + + func switchSchema(to schema: String) async throws { + let normalized = schema.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + throw DamengError(message: String(localized: "The Dameng schema name cannot be empty.")) + } + let setSchema = "SET SCHEMA \(quoteIdentifier(normalized))" + let escaped = setSchema.replacingOccurrences(of: "'", with: "''") + _ = try await executeBound( + query: "BEGIN EXECUTE IMMEDIATE '\(escaped)'; END;", + parameters: [], + rowCap: nil + ) + let selected = try await scalarText("SELECT SF_GET_SCHEMA_NAME_BY_ID(CURRENT_SCHID)") + guard selected.caseInsensitiveCompare(normalized) == .orderedSame else { + throw DamengError(message: String(localized: "Dameng did not switch to the requested schema.")) + } + activeSchema = selected + } + + func dropSchema(name: String) async throws { + guard !DamengPlugin.systemSchemaNames.contains(name.uppercased()) else { + throw DamengError(message: String(localized: "Dameng system schemas cannot be dropped.")) + } + guard name.caseInsensitiveCompare(activeSchema ?? "") != .orderedSame else { + throw DamengError(message: String(localized: "Switch away from a schema before dropping it.")) + } + _ = try await execute(query: "DROP SCHEMA \(quoteIdentifier(name)) CASCADE") + } + + func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + func createViewTemplate() -> String? { + "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + } + + func editViewFallbackTemplate(viewName: String) -> String? { + "CREATE OR REPLACE VIEW \(quoteIdentifier(viewName)) AS\nSELECT * FROM table_name;" + } + + func castColumnToText(_ column: String) -> String { + "CAST(\(column) AS VARCHAR(8188))" + } + + func buildExplainQuery(_ sql: String) -> String? { + "EXPLAIN \(sql)" + } + + private func executeBound( + query: String, + parameters: [PluginCellValue], + rowCap: Int? + ) async throws -> PluginQueryResult { + let bound = parameters.isEmpty ? query : try DamengParameterBinder.bind(query: query, parameters: parameters) + let expectsRows = DamengStatementClassifier.expectsRows(bound) + let capped = expectsRows ? DamengStatementClassifier.applyingRowCap(rowCap, to: bound) : bound + let result = try await connection.execute(capped, expectsRows: expectsRows, rowCap: rowCap) + return PluginQueryResult( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + rows: result.rows, + rowsAffected: result.rowsAffected, + executionTime: result.executionTime, + isTruncated: result.isTruncated + ) + } + + private func scalarText(_ query: String) async throws -> String { + let result = try await executeBound(query: query, parameters: [], rowCap: 1) + guard let value = result.rows.first?.first?.asText else { + throw DamengError(message: String(localized: "Dameng returned an empty metadata result.")) + } + return value + } +} diff --git a/Plugins/DamengDriverPlugin/DamengPluginDriver+Editing.swift b/Plugins/DamengDriverPlugin/DamengPluginDriver+Editing.swift new file mode 100644 index 000000000..389e1ec28 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengPluginDriver+Editing.swift @@ -0,0 +1,329 @@ +import Foundation +import TableProPluginKit + +extension DamengPluginDriver { + func generateStatements( + table: String, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + generateStatements( + table: table, + schema: nil, + columns: columns, + primaryKeyColumns: primaryKeyColumns, + changes: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + + func generateStatements( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])]? { + let qualifiedTable = qualifiedName(schema: schema, object: table) + var statements: [(statement: String, parameters: [PluginCellValue])] = [] + for change in changes { + switch change.type { + case .insert: + guard insertedRowIndices.contains(change.rowIndex), + let row = insertedRowData[change.rowIndex], + let statement = insertStatement(table: qualifiedTable, columns: columns, values: row) else { + continue + } + statements.append(statement) + case .update: + guard let statement = updateStatement( + table: qualifiedTable, + columns: columns, + primaryKeyColumns: primaryKeyColumns, + change: change + ) else { + continue + } + statements.append(statement) + case .delete: + guard deletedRowIndices.contains(change.rowIndex), + let statement = deleteStatement( + table: qualifiedTable, + columns: columns, + primaryKeyColumns: primaryKeyColumns, + change: change + ) else { + continue + } + statements.append(statement) + } + } + return statements.isEmpty ? nil : statements + } + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + let table = qualifiedName(schema: nil, object: definition.tableName) + let primaryKeys = definition.columns.filter(\.isPrimaryKey) + let inlinePrimaryKey = primaryKeys.count == 1 + var definitions = definition.columns.map { columnDefinition($0, inlinePrimaryKey: inlinePrimaryKey) } + if primaryKeys.count > 1 { + definitions.append("PRIMARY KEY (\(primaryKeys.map { quoteIdentifier($0.name) }.joined(separator: ", ")))") + } + definitions.append(contentsOf: definition.foreignKeys.map(foreignKeyDefinition)) + let createTable = "CREATE TABLE \(table) (\n " + definitions.joined(separator: ",\n ") + "\n)" + let indexes = definition.indexes.map { indexDefinition($0, table: table) } + let comments = definition.columns.compactMap { column -> String? in + guard let comment = column.comment, !comment.isEmpty else { return nil } + return "COMMENT ON COLUMN \(table).\(quoteIdentifier(column.name)) IS \(stringLiteral(comment))" + } + let statements = [createTable] + indexes + comments + guard statements.count > 1 else { + return createTable + ";" + } + let commands = statements.map { " EXECUTE IMMEDIATE \(stringLiteral($0));" } + return "BEGIN\n" + commands.joined(separator: "\n") + "\nEND;" + } + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + columnDefinition(column, inlinePrimaryKey: false) + } + + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { + indexDefinition(index, table: tableName.map { qualifiedName(schema: nil, object: $0) } ?? "\"table\"") + } + + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { + foreignKeyDefinition(fk) + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + "ALTER TABLE \(qualifiedName(schema: nil, object: table)) ADD \(columnDefinition(column, inlinePrimaryKey: false))" + } + + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? { + let qualifiedTable = qualifiedName(schema: nil, object: table) + var statements: [String] = [] + if oldColumn.name != newColumn.name { + statements.append( + "ALTER TABLE \(qualifiedTable) RENAME COLUMN \(quoteIdentifier(oldColumn.name)) TO \(quoteIdentifier(newColumn.name))" + ) + } + let attributesChanged = oldColumn.dataType != newColumn.dataType || + oldColumn.isNullable != newColumn.isNullable || + oldColumn.defaultValue != newColumn.defaultValue + if attributesChanged { + statements.append( + "ALTER TABLE \(qualifiedTable) MODIFY \(columnDefinition(newColumn, inlinePrimaryKey: false))" + ) + } + return statements.isEmpty ? nil : statements.joined(separator: ";\n") + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(qualifiedName(schema: nil, object: table)) DROP COLUMN \(quoteIdentifier(columnName))" + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + indexDefinition(index, table: qualifiedName(schema: nil, object: table)) + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX \(qualifiedName(schema: nil, object: indexName))" + } + + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { + "ALTER TABLE \(qualifiedName(schema: nil, object: table)) ADD \(foreignKeyDefinition(fk))" + } + + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { + "ALTER TABLE \(qualifiedName(schema: nil, object: table)) DROP CONSTRAINT \(quoteIdentifier(constraintName))" + } + + func generateModifyPrimaryKeySQL( + table: String, + oldColumns: [String], + newColumns: [String], + constraintName: String? + ) -> [String]? { + let qualifiedTable = qualifiedName(schema: nil, object: table) + var statements: [String] = [] + if !oldColumns.isEmpty { + if let constraintName, !constraintName.isEmpty { + statements.append("ALTER TABLE \(qualifiedTable) DROP CONSTRAINT \(quoteIdentifier(constraintName))") + } else { + statements.append("ALTER TABLE \(qualifiedTable) DROP PRIMARY KEY") + } + } + if !newColumns.isEmpty { + let columns = newColumns.map(quoteIdentifier).joined(separator: ", ") + statements.append("ALTER TABLE \(qualifiedTable) ADD PRIMARY KEY (\(columns))") + } + return statements.isEmpty ? nil : statements + } + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { + ["TRUNCATE TABLE \(qualifiedName(schema: schema, object: table))"] + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + let normalizedType = objectType.uppercased() == "VIEW" ? "VIEW" : "TABLE" + let suffix = cascade && normalizedType == "TABLE" ? " CASCADE" : "" + return "DROP \(normalizedType) \(qualifiedName(schema: schema, object: name))\(suffix)" + } + + func defaultExportQuery(table: String, schema: String?) -> String? { + "SELECT * FROM \(qualifiedName(schema: schema, object: table))" + } + + private func insertStatement( + table: String, + columns: [String], + values: [PluginCellValue] + ) -> (statement: String, parameters: [PluginCellValue])? { + var insertColumns: [String] = [] + var placeholders: [String] = [] + var parameters: [PluginCellValue] = [] + for (index, value) in values.enumerated() where index < columns.count { + insertColumns.append(quoteIdentifier(columns[index])) + if value.asText == "__DEFAULT__" { + placeholders.append("DEFAULT") + } else { + placeholders.append("?") + parameters.append(value) + } + } + guard !insertColumns.isEmpty else { return nil } + return ( + "INSERT INTO \(table) (\(insertColumns.joined(separator: ", "))) VALUES (\(placeholders.joined(separator: ", ")))", + parameters + ) + } + + private func updateStatement( + table: String, + columns: [String], + primaryKeyColumns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [PluginCellValue])? { + guard !change.cellChanges.isEmpty, let original = change.originalRow else { return nil } + var parameters = change.cellChanges.map(\.newValue) + let assignments = change.cellChanges.map { "\(quoteIdentifier($0.columnName)) = ?" } + guard let predicate = rowPredicate( + columns: columns, + primaryKeyColumns: primaryKeyColumns, + original: original, + parameters: ¶meters + ) else { + return nil + } + return ( + "UPDATE \(table) SET \(assignments.joined(separator: ", ")) WHERE \(predicate) AND ROWNUM = 1", + parameters + ) + } + + private func deleteStatement( + table: String, + columns: [String], + primaryKeyColumns: [String], + change: PluginRowChange + ) -> (statement: String, parameters: [PluginCellValue])? { + guard let original = change.originalRow else { return nil } + var parameters: [PluginCellValue] = [] + guard let predicate = rowPredicate( + columns: columns, + primaryKeyColumns: primaryKeyColumns, + original: original, + parameters: ¶meters + ) else { + return nil + } + return ("DELETE FROM \(table) WHERE \(predicate) AND ROWNUM = 1", parameters) + } + + private func rowPredicate( + columns: [String], + primaryKeyColumns: [String], + original: [PluginCellValue], + parameters: inout [PluginCellValue] + ) -> String? { + let selectedColumns = primaryKeyColumns.isEmpty ? columns : primaryKeyColumns + var predicates: [String] = [] + for column in selectedColumns { + guard let index = columns.firstIndex(of: column), index < original.count else { continue } + let value = original[index] + if value.isNull { + predicates.append("\(quoteIdentifier(column)) IS NULL") + } else { + predicates.append("\(quoteIdentifier(column)) = ?") + parameters.append(value) + } + } + return predicates.isEmpty ? nil : predicates.joined(separator: " AND ") + } + + private func columnDefinition(_ column: PluginColumnDefinition, inlinePrimaryKey: Bool) -> String { + var definition = "\(quoteIdentifier(column.name)) \(column.dataType.uppercased())" + if column.autoIncrement { + definition += " IDENTITY(1,1)" + } + if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + definition += " DEFAULT \(defaultExpression(defaultValue))" + } + if !column.isNullable { + definition += " NOT NULL" + } + if inlinePrimaryKey, column.isPrimaryKey { + definition += " PRIMARY KEY" + } + return definition + } + + private func indexDefinition(_ index: PluginIndexDefinition, table: String) -> String { + let unique = index.isUnique ? "UNIQUE " : "" + let columns = index.columns.map(quoteIdentifier).joined(separator: ", ") + return "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) ON \(table) (\(columns))" + } + + private func foreignKeyDefinition(_ foreignKey: PluginForeignKeyDefinition) -> String { + let columns = foreignKey.columns.map(quoteIdentifier).joined(separator: ", ") + let referencedColumns = foreignKey.referencedColumns.map(quoteIdentifier).joined(separator: ", ") + let referencedTable = qualifiedName(schema: foreignKey.referencedSchema, object: foreignKey.referencedTable) + let constraint = foreignKey.name.isEmpty ? "" : "CONSTRAINT \(quoteIdentifier(foreignKey.name)) " + let onDelete = foreignKey.onDelete.uppercased() == "NO ACTION" ? "" : " ON DELETE \(foreignKey.onDelete)" + return "\(constraint)FOREIGN KEY (\(columns)) REFERENCES \(referencedTable) (\(referencedColumns))\(onDelete)" + } + + private func defaultExpression(_ value: String) -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + let uppercased = normalized.uppercased() + if PluginNumericLiteral.isValid(normalized) || [ + "NULL", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "SYSDATE" + ].contains(uppercased) || normalized.hasPrefix("'") || normalized.hasPrefix("\"") { + return normalized + } + return stringLiteral(normalized) + } + + private func stringLiteral(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "''"))'" + } + + private func qualifiedName(schema: String?, object: String) -> String { + "\(quoteIdentifier(effectiveSchema(schema))).\(quoteIdentifier(object))" + } +} diff --git a/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift b/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift new file mode 100644 index 000000000..d49e29677 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift @@ -0,0 +1,343 @@ +import Foundation +import TableProPluginKit + +extension DamengPluginDriver { + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + let result = try await executeParameterized( + query: """ + SELECT TABLE_NAME, 'TABLE' AS TABLE_TYPE + FROM ALL_TABLES + WHERE OWNER = ? + UNION ALL + SELECT VIEW_NAME, 'VIEW' + FROM ALL_VIEWS + WHERE OWNER = ? + ORDER BY 1 + """, + parameters: [.text(effectiveSchema(schema)), .text(effectiveSchema(schema))] + ) + return result.rows.compactMap { row in + guard let name = row[safe: 0]?.asText else { return nil } + return PluginTableInfo(name: name, type: row[safe: 1]?.asText ?? "TABLE") + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + let owner = effectiveSchema(schema) + let result = try await executeParameterized( + query: """ + SELECT c.COLUMN_NAME, + c.DATA_TYPE, + c.DATA_LENGTH, + c.DATA_PRECISION, + c.DATA_SCALE, + c.NULLABLE, + CAST(c.DATA_DEFAULT AS VARCHAR(8188)) AS DATA_DEFAULT, + CAST(com.COMMENTS AS VARCHAR(8188)) AS COMMENTS, + CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 'Y' ELSE 'N' END AS IS_PK + FROM ALL_TAB_COLUMNS c + LEFT JOIN ( + SELECT acc.COLUMN_NAME + FROM ALL_CONS_COLUMNS acc + JOIN ALL_CONSTRAINTS ac + ON acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME + AND acc.OWNER = ac.OWNER + WHERE ac.CONSTRAINT_TYPE = 'P' + AND ac.OWNER = ? + AND ac.TABLE_NAME = ? + ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME + LEFT JOIN ALL_COL_COMMENTS com + ON c.OWNER = com.OWNER + AND c.TABLE_NAME = com.TABLE_NAME + AND c.COLUMN_NAME = com.COLUMN_NAME + WHERE c.OWNER = ? + AND c.TABLE_NAME = ? + ORDER BY c.COLUMN_ID + """, + parameters: [.text(owner), .text(table), .text(owner), .text(table)] + ) + return result.rows.compactMap { row in + guard let name = row[safe: 0]?.asText else { return nil } + return PluginColumnInfo( + name: name, + dataType: DamengSchemaValue.fullType( + name: row[safe: 1]?.asText ?? "VARCHAR", + length: row[safe: 2]?.asText, + precision: row[safe: 3]?.asText, + scale: row[safe: 4]?.asText + ), + isNullable: row[safe: 5]?.asText == "Y", + isPrimaryKey: row[safe: 8]?.asText == "Y", + defaultValue: DamengSchemaValue.nonEmpty(row[safe: 6]?.asText), + comment: DamengSchemaValue.nonEmpty(row[safe: 7]?.asText) + ) + } + } + + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { + let owner = effectiveSchema(schema) + let result = try await executeParameterized( + query: """ + SELECT c.TABLE_NAME, + c.COLUMN_NAME, + c.DATA_TYPE, + c.DATA_LENGTH, + c.DATA_PRECISION, + c.DATA_SCALE, + c.NULLABLE, + CAST(c.DATA_DEFAULT AS VARCHAR(8188)) AS DATA_DEFAULT, + CAST(com.COMMENTS AS VARCHAR(8188)) AS COMMENTS, + CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 'Y' ELSE 'N' END AS IS_PK + FROM ALL_TAB_COLUMNS c + LEFT JOIN ( + SELECT acc.OWNER, acc.TABLE_NAME, acc.COLUMN_NAME + FROM ALL_CONS_COLUMNS acc + JOIN ALL_CONSTRAINTS ac + ON acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME + AND acc.OWNER = ac.OWNER + WHERE ac.CONSTRAINT_TYPE = 'P' + ) pk + ON c.OWNER = pk.OWNER + AND c.TABLE_NAME = pk.TABLE_NAME + AND c.COLUMN_NAME = pk.COLUMN_NAME + LEFT JOIN ALL_COL_COMMENTS com + ON c.OWNER = com.OWNER + AND c.TABLE_NAME = com.TABLE_NAME + AND c.COLUMN_NAME = com.COLUMN_NAME + WHERE c.OWNER = ? + ORDER BY c.TABLE_NAME, c.COLUMN_ID + """, + parameters: [.text(owner)] + ) + var columnsByTable: [String: [PluginColumnInfo]] = [:] + for row in result.rows { + guard let table = row[safe: 0]?.asText, + let name = row[safe: 1]?.asText else { + continue + } + columnsByTable[table, default: []].append(PluginColumnInfo( + name: name, + dataType: DamengSchemaValue.fullType( + name: row[safe: 2]?.asText ?? "VARCHAR", + length: row[safe: 3]?.asText, + precision: row[safe: 4]?.asText, + scale: row[safe: 5]?.asText + ), + isNullable: row[safe: 6]?.asText == "Y", + isPrimaryKey: row[safe: 9]?.asText == "Y", + defaultValue: DamengSchemaValue.nonEmpty(row[safe: 7]?.asText), + comment: DamengSchemaValue.nonEmpty(row[safe: 8]?.asText) + )) + } + return columnsByTable + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + let result = try await executeParameterized( + query: """ + SELECT i.INDEX_NAME, + i.UNIQUENESS, + ic.COLUMN_NAME, + CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 'Y' ELSE 'N' END AS IS_PK + FROM ALL_INDEXES i + JOIN ALL_IND_COLUMNS ic + ON i.INDEX_NAME = ic.INDEX_NAME + AND i.OWNER = ic.INDEX_OWNER + LEFT JOIN ALL_CONSTRAINTS c + ON i.INDEX_NAME = c.INDEX_NAME + AND i.OWNER = c.OWNER + AND c.CONSTRAINT_TYPE = 'P' + WHERE i.TABLE_NAME = ? + AND i.OWNER = ? + ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION + """, + parameters: [.text(table), .text(effectiveSchema(schema))] + ) + var grouped: [String: (isUnique: Bool, isPrimary: Bool, columns: [String])] = [:] + for row in result.rows { + guard let name = row[safe: 0]?.asText, let column = row[safe: 2]?.asText else { continue } + var item = grouped[name] ?? (false, false, []) + item.isUnique = row[safe: 1]?.asText == "UNIQUE" + item.isPrimary = row[safe: 3]?.asText == "Y" + item.columns.append(column) + grouped[name] = item + } + return grouped.map { name, item in + PluginIndexInfo( + name: name, + columns: item.columns, + isUnique: item.isUnique, + isPrimary: item.isPrimary + ) + }.sorted { $0.name < $1.name } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + let result = try await executeParameterized( + query: """ + SELECT ac.CONSTRAINT_NAME, + acc.COLUMN_NAME, + rc.TABLE_NAME, + rcc.COLUMN_NAME, + ac.DELETE_RULE, + rc.OWNER + FROM ALL_CONSTRAINTS ac + JOIN ALL_CONS_COLUMNS acc + ON ac.CONSTRAINT_NAME = acc.CONSTRAINT_NAME + AND ac.OWNER = acc.OWNER + JOIN ALL_CONSTRAINTS rc + ON ac.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME + AND ac.R_OWNER = rc.OWNER + JOIN ALL_CONS_COLUMNS rcc + ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME + AND rc.OWNER = rcc.OWNER + AND acc.POSITION = rcc.POSITION + WHERE ac.CONSTRAINT_TYPE = 'R' + AND ac.TABLE_NAME = ? + AND ac.OWNER = ? + ORDER BY ac.CONSTRAINT_NAME, acc.POSITION + """, + parameters: [.text(table), .text(effectiveSchema(schema))] + ) + return result.rows.compactMap { row in + guard let name = row[safe: 0]?.asText, + let column = row[safe: 1]?.asText, + let referencedTable = row[safe: 2]?.asText, + let referencedColumn = row[safe: 3]?.asText else { + return nil + } + return PluginForeignKeyInfo( + name: name, + column: column, + referencedTable: referencedTable, + referencedColumn: referencedColumn, + referencedSchema: row[safe: 5]?.asText, + onDelete: row[safe: 4]?.asText ?? "NO ACTION" + ) + } + } + + func fetchSchemas() async throws -> [String] { + let result = try await execute(query: """ + SELECT OBJECT_NAME + FROM ALL_OBJECTS + WHERE OBJECT_TYPE = 'SCH' + ORDER BY OBJECT_NAME + """) + return result.rows.compactMap { $0.first?.asText } + } + + func fetchDatabases() async throws -> [String] { + try await fetchSchemas() + } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + let result = try await executeParameterized( + query: "SELECT COUNT(*) FROM ALL_TABLES WHERE OWNER = ?", + parameters: [.text(database)] + ) + return PluginDatabaseMetadata( + name: database, + tableCount: result.rows.first?.first?.asText.flatMap(Int.init), + isSystemDatabase: DamengPlugin.systemSchemaNames.contains(database.uppercased()) + ) + } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + let result = try await executeParameterized( + query: """ + SELECT t.NUM_ROWS, t.AVG_ROW_LEN, CAST(c.COMMENTS AS VARCHAR(8188)) AS COMMENTS + FROM ALL_TABLES t + LEFT JOIN ALL_TAB_COMMENTS c + ON t.OWNER = c.OWNER + AND t.TABLE_NAME = c.TABLE_NAME + WHERE t.OWNER = ? + AND t.TABLE_NAME = ? + """, + parameters: [.text(effectiveSchema(schema)), .text(table)] + ) + guard let row = result.rows.first else { + return PluginTableMetadata(tableName: table) + } + return PluginTableMetadata( + tableName: table, + avgRowLength: row[safe: 1]?.asText.flatMap(Int64.init), + rowCount: row[safe: 0]?.asText.flatMap(Int64.init), + comment: DamengSchemaValue.nonEmpty(row[safe: 2]?.asText), + engine: "DM8" + ) + } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { + let result = try await executeParameterized( + query: "SELECT CAST(TEXT AS VARCHAR(8188)) FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?", + parameters: [.text(effectiveSchema(schema)), .text(view)] + ) + return result.rows.first?.first?.asText ?? "" + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + let owner = effectiveSchema(schema) + let columns = try await fetchColumns(table: table, schema: owner) + guard !columns.isEmpty else { + throw DamengError(message: String(localized: "Dameng did not return the table definition.")) + } + var definitions = columns.map { column in + var definition = " \(quoteIdentifier(column.name)) \(column.dataType)" + if let defaultValue = column.defaultValue { + definition += " DEFAULT \(defaultValue)" + } + if !column.isNullable { + definition += " NOT NULL" + } + return definition + } + let primaryKeys = columns.filter(\.isPrimaryKey).map { quoteIdentifier($0.name) } + if !primaryKeys.isEmpty { + definitions.append(" PRIMARY KEY (\(primaryKeys.joined(separator: ", ")))") + } + return "CREATE TABLE \(quoteIdentifier(owner)).\(quoteIdentifier(table)) (\n" + + definitions.joined(separator: ",\n") + "\n);" + } + + func effectiveSchema(_ schema: String?) -> String { + if let schema, !schema.isEmpty { + return schema + } + if let currentSchema, !currentSchema.isEmpty { + return currentSchema + } + if !config.database.isEmpty { + return config.database + } + return config.username.uppercased() + } +} + +enum DamengSchemaValue { + static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + static func fullType(name: String, length: String?, precision: String?, scale: String?) -> String { + let type = name.uppercased() + if ["CHAR", "CHARACTER", "VARCHAR", "VARCHAR2", "BINARY", "VARBINARY"].contains(type), + let length, let lengthValue = Int(length), lengthValue > 0 { + return "\(type)(\(lengthValue))" + } + if ["DEC", "DECIMAL", "NUMERIC", "NUMBER"].contains(type), + let precision, let precisionValue = Int(precision), precisionValue > 0 { + if let scale, let scaleValue = Int(scale), scaleValue > 0 { + return "\(type)(\(precisionValue),\(scaleValue))" + } + return "\(type)(\(precisionValue))" + } + if type == "TIMESTAMP", let scale, let scaleValue = Int(scale), scaleValue > 0 { + return "TIMESTAMP(\(scaleValue))" + } + return type + } +} diff --git a/Plugins/DamengDriverPlugin/DamengStatementClassifier.swift b/Plugins/DamengDriverPlugin/DamengStatementClassifier.swift new file mode 100644 index 000000000..4a0a7208d --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengStatementClassifier.swift @@ -0,0 +1,169 @@ +import Foundation + +enum DamengStatementClassifier { + private static let resultKeywords: Set = [ + "DESC", "DESCRIBE", "EXPLAIN", "SELECT", "SHOW", "VALUES", "WITH" + ] + + static func expectsRows(_ query: String) -> Bool { + guard let keyword = firstKeyword(query) else { return false } + return resultKeywords.contains(keyword) + } + + static func applyingRowCap(_ rowCap: Int?, to query: String) -> String { + guard let rowCap, rowCap > 0, + let keyword = firstKeyword(query), + keyword == "SELECT" || keyword == "WITH", + let statement = singleStatement(query) else { + return query + } + let serverCap = rowCap == Int.max ? rowCap : rowCap + 1 + return "SELECT * FROM (\n\(statement)\n) TABLEPRO_ROW_CAP WHERE ROWNUM <= \(serverCap)" + } + + private static func firstKeyword(_ query: String) -> String? { + let bytes = Array(query.utf8) + var index = 0 + while index < bytes.count { + if isWhitespace(bytes[index]) || bytes[index] == 0x28 || bytes[index] == 0x3B { + index += 1 + continue + } + if index + 1 < bytes.count, bytes[index] == 0x2D, bytes[index + 1] == 0x2D { + index = skipLineComment(bytes, from: index + 2) + continue + } + if index + 1 < bytes.count, bytes[index] == 0x2F, bytes[index + 1] == 0x2A { + index = skipBlockComment(bytes, from: index + 2) + continue + } + let start = index + while index < bytes.count, isKeywordByte(bytes[index]) { + index += 1 + } + guard index > start else { return nil } + return String(decoding: bytes[start.. String? { + let bytes = Array(query.utf8) + var state: UInt8 = 0 + var alternativeCloser: UInt8 = 0 + var blockDepth = 0 + var semicolonIndex: Int? + var index = 0 + while index < bytes.count { + let byte = bytes[index] + let next = index + 1 < bytes.count ? bytes[index + 1] : nil + if state == 0 { + if byte == 0x27 { + state = 1 + } else if byte == 0x22 { + state = 2 + } else if byte == 0x2D, next == 0x2D { + state = 3 + index += 1 + } else if byte == 0x2F, next == 0x2A { + state = 4 + blockDepth = 1 + index += 1 + } else if isAlternativeQuoteStart(bytes, at: index) { + alternativeCloser = alternativeQuoteCloser(for: bytes[index + 2]) + state = 5 + index += 2 + } else if byte == 0x3B { + guard semicolonIndex == nil else { return nil } + semicolonIndex = index + } + } else if state == 1, byte == 0x27 { + if next == 0x27 { + index += 1 + } else { + state = 0 + } + } else if state == 2, byte == 0x22 { + if next == 0x22 { + index += 1 + } else { + state = 0 + } + } else if state == 3, (byte == 0x0A || byte == 0x0D) { + state = 0 + } else if state == 4 { + if byte == 0x2F, next == 0x2A { + blockDepth += 1 + index += 1 + } else if byte == 0x2A, next == 0x2F { + blockDepth -= 1 + index += 1 + if blockDepth == 0 { state = 0 } + } + } else if state == 5, byte == alternativeCloser, next == 0x27 { + state = 0 + index += 1 + } + index += 1 + } + + let end: Int + if let semicolonIndex { + guard bytes.suffix(from: semicolonIndex + 1).allSatisfy(isWhitespace) else { return nil } + end = semicolonIndex + } else { + end = bytes.count + } + return String(decoding: bytes[.. Int { + var index = start + while index < bytes.count, bytes[index] != 0x0A, bytes[index] != 0x0D { + index += 1 + } + return index + } + + private static func skipBlockComment(_ bytes: [UInt8], from start: Int) -> Int { + var index = start + var depth = 1 + while index + 1 < bytes.count { + if bytes[index] == 0x2F, bytes[index + 1] == 0x2A { + depth += 1 + index += 2 + } else if bytes[index] == 0x2A, bytes[index + 1] == 0x2F { + depth -= 1 + index += 2 + if depth == 0 { return index } + } else { + index += 1 + } + } + return bytes.count + } + + private static func isWhitespace(_ byte: UInt8) -> Bool { + byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D + } + + private static func isKeywordByte(_ byte: UInt8) -> Bool { + (0x41...0x5A).contains(byte) || (0x61...0x7A).contains(byte) || byte == 0x5F + } + + private static func isAlternativeQuoteStart(_ bytes: [UInt8], at index: Int) -> Bool { + guard index + 2 < bytes.count else { return false } + return (bytes[index] == 0x51 || bytes[index] == 0x71) && bytes[index + 1] == 0x27 + } + + private static func alternativeQuoteCloser(for opener: UInt8) -> UInt8 { + switch opener { + case 0x5B: return 0x5D + case 0x28: return 0x29 + case 0x7B: return 0x7D + case 0x3C: return 0x3E + default: return opener + } + } +} diff --git a/Plugins/DamengDriverPlugin/Info.plist b/Plugins/DamengDriverPlugin/Info.plist new file mode 100644 index 000000000..c3f02778f --- /dev/null +++ b/Plugins/DamengDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + 19 + TableProProvidesDatabaseTypeIds + + Dameng + + + diff --git a/Plugins/DamengDriverPlugin/README.md b/Plugins/DamengDriverPlugin/README.md new file mode 100644 index 000000000..6477f1ad0 --- /dev/null +++ b/Plugins/DamengDriverPlugin/README.md @@ -0,0 +1,43 @@ +# Dameng DM8 Driver Plugin + +This plugin adds native Dameng DM8 connectivity to TablePro without requiring a local DM client installation. It supports schema browsing and switching, SQL execution, row editing, transactions, DDL helpers, EXPLAIN plans, and DM8-aware completions while typing. + +## Architecture + +The driver has three layers: + +- **Swift** (`Plugins/DamengDriverPlugin/`) implements `PluginDatabaseDriver`, schema and editing APIs, SQL completion metadata, safe parameter substitution, and result conversion. +- **C ABI** (`CDameng/CDameng.h`) exposes opaque connection and result handles. Swift copies returned values before releasing their Rust-owned storage. +- **Rust** (`Native/DamengBridge/`) owns the DM8 wire connection, transaction state, encoding detection, row limits, and panic boundary. It builds as a static library for both Apple Silicon and Intel Macs. + +The bridge vendors a reviewed `rust-dameng` snapshot under `Native/DamengBridge/Vendor/`. TablePro's compatibility patches add multi-column results, binary DECIMAL decoding, bounded response parsing, and DM8's text EXPLAIN response. Keep `Vendor/UPSTREAM.md`, the vendored crates, and their MIT license notices in sync when updating the snapshot. + +## Build + +The build script installs the pinned Rust toolchain and creates `Libs/libdameng_bridge.a` as a universal archive: + +```bash +scripts/build-dameng.sh +scripts/generate-project.sh +xcodebuild -project TablePro.xcodeproj -scheme DamengDriver \ + -configuration Debug build CODE_SIGNING_ALLOWED=NO +``` + +Use `scripts/build-dameng.sh arm64` or `x86_64` only for architecture-specific diagnostics. Do not commit generated files under `Libs/` or `target/`. + +## Tests + +Run protocol, bridge, and Swift tests before submitting a change: + +```bash +cargo test --manifest-path Native/DamengBridge/Vendor/dameng-protocol/Cargo.toml +cargo test --manifest-path Native/DamengBridge/Cargo.toml +xcodebuild -project TablePro.xcodeproj -scheme DamengDriverTests \ + -configuration Debug build-for-testing CODE_SIGNING_ALLOWED=NO +``` + +Integration tests require an isolated DM8 server, such as a container running in OrbStack. Set `TABLEPRO_DM8_INTEGRATION=1`, `DM_HOST`, `DM_PORT`, `DM_USER`, and `DM_PASSWORD`, then run the built `DamengDriverTests.xctest` bundle with `xcrun xctest`. Tests create a unique temporary schema and remove it afterward. + +## Security and Limitations + +The parameter binder recognizes placeholders only in SQL code, escapes text literals, and encodes bytes with `HEXTORAW`. Response bodies and LOB content are capped at 64 MiB. Native TLS is not available; use SSH, SOCKS, or Cloudflare tunneling for untrusted networks. Native binary and off-row LOB reads remain unsupported by the transport; use `RAWTOHEX` for binary values and cast CLOB values to `VARCHAR`. diff --git a/Plugins/DamengDriverPluginTests/DamengPluginDriverTests.swift b/Plugins/DamengDriverPluginTests/DamengPluginDriverTests.swift new file mode 100644 index 000000000..cc064b03a --- /dev/null +++ b/Plugins/DamengDriverPluginTests/DamengPluginDriverTests.swift @@ -0,0 +1,385 @@ +import Foundation +import TableProPluginKit +import XCTest + +final class DamengPluginDriverTests: XCTestCase { + func testTypingSuggestionsIncludeDM8StatementsAndDialectSymbols() { + let completions = Set(DamengPlugin.statementCompletions.map(\.label)) + + XCTAssertTrue(completions.isSuperset(of: ["SET SCHEMA", "CREATE SCHEMA", "EXPLAIN", "CONNECT BY"])) + XCTAssertTrue(DamengPlugin.sqlDialect?.keywords.contains("ROWNUM") == true) + XCTAssertTrue(DamengPlugin.sqlDialect?.functions.contains("NVL") == true) + XCTAssertTrue(DamengPlugin.sqlDialect?.dataTypes.contains("VARCHAR2") == true) + + let driver = DamengPluginDriver(config: testConfig(database: "APP")) + XCTAssertEqual(driver.buildExplainQuery("SELECT 1"), "EXPLAIN SELECT 1") + } + + func testDDLGenerationQuotesNamesAndPreservesConstraints() throws { + let driver = DamengPluginDriver(config: testConfig(database: "APP")) + let definition = PluginCreateTableDefinition( + tableName: "order", + columns: [ + PluginColumnDefinition( + name: "id", + dataType: "int", + isNullable: false, + isPrimaryKey: true, + autoIncrement: true + ), + PluginColumnDefinition( + name: "display name", + dataType: "varchar(100)", + defaultValue: "guest's record", + comment: "customer's label" + ) + ], + indexes: [PluginIndexDefinition(name: "idx display", columns: ["display name"])], + foreignKeys: [] + ) + + let sql = try XCTUnwrap(driver.generateCreateTableSQL(definition: definition)) + + XCTAssertTrue(sql.hasPrefix("BEGIN\n")) + XCTAssertTrue(sql.contains("CREATE TABLE \"APP\".\"order\"")) + XCTAssertTrue(sql.contains("\"id\" INT IDENTITY(1,1) NOT NULL PRIMARY KEY")) + XCTAssertTrue(sql.contains("DEFAULT ''guest''''s record''")) + XCTAssertTrue(sql.contains("CREATE INDEX \"idx display\" ON \"APP\".\"order\" (\"display name\")")) + XCTAssertTrue(sql.contains("IS ''customer''''s label''")) + } + + func testRowChangesUsePrimaryKeyAndBoundValues() throws { + let driver = DamengPluginDriver(config: testConfig(database: "APP")) + let change = PluginRowChange( + rowIndex: 2, + type: .update, + cellChanges: [(1, "name", .text("old"), .text("new"))], + originalRow: [.text("42"), .text("old")] + ) + + let statements = try XCTUnwrap(driver.generateStatements( + table: "users", + schema: "APP", + columns: ["id", "name"], + primaryKeyColumns: ["id"], + changes: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + )) + + XCTAssertEqual(statements.count, 1) + XCTAssertEqual( + statements[0].statement, + "UPDATE \"APP\".\"users\" SET \"name\" = ? WHERE \"id\" = ? AND ROWNUM = 1" + ) + XCTAssertEqual(statements[0].parameters, [.text("new"), .text("42")]) + } + + func testEffectiveSchemaPreservesQuotedIdentifierCase() { + let configured = DamengPluginDriver(config: testConfig(database: "CamelCaseSchema")) + XCTAssertEqual(configured.effectiveSchema(nil), "CamelCaseSchema") + XCTAssertEqual(configured.effectiveSchema("lowercase_schema"), "lowercase_schema") + + let fallback = DamengPluginDriver(config: DriverConnectionConfig( + host: "127.0.0.1", + port: 5_236, + username: "sysdba", + password: "test-only", + database: "" + )) + XCTAssertEqual(fallback.effectiveSchema(nil), "SYSDBA") + } + + func testLiveDM8Workflow() async throws { + let environment = try liveEnvironment() + let driver = DamengPluginDriver(config: environment.config(database: "SYSDBA")) + let schema = "TP_\(UUID().uuidString.prefix(12).replacingOccurrences(of: "-", with: ""))".uppercased() + try await checked("connect") { + try await driver.connect() + } + defer { driver.disconnect() } + + _ = try await checked("create schema") { + try await driver.execute(query: "CREATE SCHEMA \(driver.quoteIdentifier(schema)) AUTHORIZATION SYSDBA") + } + do { + try await runLiveWorkflow(driver: driver, schema: schema) + try await driver.switchSchema(to: "SYSDBA") + try await driver.dropSchema(name: schema) + } catch { + try? await driver.switchSchema(to: "SYSDBA") + _ = try? await driver.execute(query: "DROP SCHEMA \(driver.quoteIdentifier(schema)) CASCADE") + throw error + } + } + + func testLiveDM8RejectsBadCredentialsAndInvalidPort() async throws { + let environment = try liveEnvironment() + let invalidPortDriver = DamengPluginDriver(config: DriverConnectionConfig( + host: environment.host, + port: 70_000, + username: environment.username, + password: environment.password, + database: "" + )) + do { + try await invalidPortDriver.connect() + XCTFail("Expected an invalid port to fail") + } catch { + XCTAssertTrue(String(describing: error).contains("65535")) + } + + let badPasswordDriver = DamengPluginDriver(config: DriverConnectionConfig( + host: environment.host, + port: environment.port, + username: environment.username, + password: "not-the-password", + database: "" + )) + do { + try await badPasswordDriver.connect() + XCTFail("Expected invalid credentials to fail") + } catch { + XCTAssertFalse(String(describing: error).isEmpty) + } + } + + private func runLiveWorkflow(driver: DamengPluginDriver, schema: String) async throws { + try await checked("switch schema") { + try await driver.switchSchema(to: schema) + } + XCTAssertEqual(driver.currentSchema, schema) + XCTAssertTrue(driver.serverVersion?.contains("DM Database Server") == true) + try await checked("ping") { + try await driver.ping() + } + + let parentDefinition = PluginCreateTableDefinition( + tableName: "PARENT", + columns: [ + PluginColumnDefinition( + name: "ID", + dataType: "INT", + isNullable: false, + isPrimaryKey: true, + autoIncrement: true + ), + PluginColumnDefinition(name: "NAME", dataType: "VARCHAR(100)", isNullable: false), + PluginColumnDefinition(name: "PAYLOAD", dataType: "VARBINARY(8188)") + ], + indexes: [PluginIndexDefinition(name: "IDX_PARENT_NAME", columns: ["NAME"])], + foreignKeys: [] + ) + _ = try await checked("create parent") { + try await driver.execute(query: try XCTUnwrap(driver.generateCreateTableSQL(definition: parentDefinition))) + } + _ = try await checked("comment parent") { + try await driver.execute(query: "COMMENT ON TABLE \"PARENT\" IS 'TablePro DM8 integration fixture'") + } + _ = try await checked("create child") { + try await driver.execute(query: """ + CREATE TABLE "CHILD" ( + "ID" INT PRIMARY KEY, + "PARENT_ID" INT, + CONSTRAINT "FK_CHILD_PARENT" FOREIGN KEY ("PARENT_ID") REFERENCES "PARENT" ("ID") ON DELETE CASCADE + ) + """) + } + _ = try await checked("create view") { + try await driver.execute( + query: "CREATE OR REPLACE VIEW \"PARENT_VIEW\" AS SELECT \"ID\", \"NAME\" FROM \"PARENT\"" + ) + } + + let hostileText = "Robert'); DROP TABLE \"PARENT\"; -- 达梦" + let payload = Data([0x00, 0x01, 0x7F, 0xFF]) + _ = try await checked("insert unicode and binary") { + try await driver.executeParameterized( + query: "INSERT INTO \"PARENT\" (\"NAME\", \"PAYLOAD\") VALUES (?, ?)", + parameters: [.text(hostileText), .bytes(payload)] + ) + } + for index in 1...4 { + _ = try await checked("insert row \(index)") { + try await driver.executeParameterized( + query: "INSERT INTO \"PARENT\" (\"NAME\") VALUES (?)", + parameters: [.text("row-\(index)")] + ) + } + } + + let valueResult = try await checked("read unicode and binary metadata") { + try await driver.execute( + query: "SELECT \"NAME\", RAWTOHEX(\"PAYLOAD\") FROM \"PARENT\" ORDER BY \"ID\"" + ) + } + XCTAssertEqual(valueResult.rows.first?[0], .text(hostileText)) + XCTAssertEqual(valueResult.rows.first?[1], .text("00017FFF")) + + let capped = try await driver.executeUserQuery( + query: "SELECT \"ID\" FROM \"PARENT\" ORDER BY \"ID\"", + rowCap: 2, + parameters: nil + ) + XCTAssertEqual(capped.rows.count, 2) + XCTAssertTrue(capped.isTruncated) + + let tables = try await driver.fetchTables(schema: schema) + XCTAssertTrue(tables.contains { $0.name == "PARENT" && $0.type == "TABLE" }) + XCTAssertTrue(tables.contains { $0.name == "PARENT_VIEW" && $0.type == "VIEW" }) + let columns = try await checked("fetch columns") { + try await driver.fetchColumns(table: "PARENT", schema: schema) + } + XCTAssertTrue(columns.contains { $0.name == "ID" && $0.isPrimaryKey }) + XCTAssertTrue(columns.contains { $0.name == "NAME" && $0.dataType == "VARCHAR(100)" }) + let allColumns = try await checked("fetch completion columns") { + try await driver.fetchAllColumns(schema: schema) + } + XCTAssertEqual(allColumns["PARENT"]?.map(\.name), ["ID", "NAME", "PAYLOAD"]) + XCTAssertEqual(allColumns["PARENT_VIEW"]?.map(\.name), ["ID", "NAME"]) + let indexes = try await checked("fetch indexes") { + try await driver.fetchIndexes(table: "PARENT", schema: schema) + } + XCTAssertTrue(indexes.contains { $0.name == "IDX_PARENT_NAME" && $0.columns == ["NAME"] }) + let foreignKeys = try await checked("fetch foreign keys") { + try await driver.fetchForeignKeys(table: "CHILD", schema: schema) + } + XCTAssertTrue(foreignKeys.contains { + $0.name == "FK_CHILD_PARENT" && $0.referencedTable == "PARENT" && $0.onDelete == "CASCADE" + }) + let emptyForeignKeys = try await checked("fetch empty foreign keys") { + try await driver.fetchForeignKeys(table: "PARENT", schema: schema) + } + XCTAssertTrue(emptyForeignKeys.isEmpty) + try await checked("ping after empty metadata") { + try await driver.ping() + } + let viewDefinition = try await checked("fetch view definition") { + try await driver.fetchViewDefinition(view: "PARENT_VIEW", schema: schema) + } + XCTAssertTrue(viewDefinition.contains("PARENT")) + let tableDDL = try await checked("fetch table DDL") { + try await driver.fetchTableDDL(table: "PARENT", schema: schema) + } + XCTAssertTrue(tableDDL.contains("PRIMARY KEY")) + let metadata = try await checked("fetch table metadata") { + try await driver.fetchTableMetadata(table: "PARENT", schema: schema) + } + XCTAssertEqual(metadata.engine, "DM8") + XCTAssertEqual(metadata.comment, "TablePro DM8 integration fixture") + + let explain = try await checked("explain") { + try await driver.execute(query: try XCTUnwrap(driver.buildExplainQuery("SELECT * FROM \"PARENT\""))) + } + XCTAssertEqual(explain.columns, ["PLAN"]) + XCTAssertTrue(explain.rows.first?.first?.asText?.contains("#") == true) + try await checked("ping after explain") { + try await driver.ping() + } + + try await checked("begin transaction") { + try await driver.beginTransaction() + } + _ = try await checked("insert rollback row") { + try await driver.execute(query: "INSERT INTO \"PARENT\" (\"NAME\") VALUES ('rollback-row')") + } + try await checked("rollback transaction") { + try await driver.rollbackTransaction() + } + let rollbackCount = try await checked("verify rollback") { + try await driver.execute(query: "SELECT COUNT(*) FROM \"PARENT\" WHERE \"NAME\" = 'rollback-row'") + } + XCTAssertEqual(rollbackCount.rows.first?.first, .text("0")) + + do { + _ = try await driver.execute(query: "SELECT * FROM \"MISSING_TABLE\"") + XCTFail("Expected an invalid query to fail") + } catch { + try await driver.ping() + } + + do { + try await driver.switchSchema(to: "\"; DROP SCHEMA \(schema) CASCADE; --") + XCTFail("Expected an invalid schema to fail") + } catch { + let schemas = try await driver.fetchSchemas() + XCTAssertTrue(schemas.contains(schema)) + } + + do { + try await driver.dropSchema(name: "SYSDBA") + XCTFail("Expected a system schema drop to be rejected") + } catch { + XCTAssertTrue(String(describing: error).contains("system")) + } + + try await withThrowingTaskGroup(of: PluginCellValue?.self) { group in + for _ in 0..<8 { + group.addTask { + let result = try await driver.execute(query: "SELECT USER FROM DUAL") + return result.rows.first?.first + } + } + for try await value in group { + XCTAssertEqual(value, .text("SYSDBA")) + } + } + } + + private func liveEnvironment() throws -> LiveEnvironment { + let environment = ProcessInfo.processInfo.environment + guard environment["TABLEPRO_DM8_INTEGRATION"] == "1" else { + throw XCTSkip("Set TABLEPRO_DM8_INTEGRATION=1 to run against DM8 in OrbStack") + } + guard let host = environment["DM_HOST"], + let portText = environment["DM_PORT"], + let port = Int(portText), + let username = environment["DM_USER"], + let password = environment["DM_PASSWORD"] else { + XCTFail("DM_HOST, DM_PORT, DM_USER, and DM_PASSWORD are required") + throw XCTSkip("DM8 integration environment is incomplete") + } + return LiveEnvironment(host: host, port: port, username: username, password: password) + } + + @discardableResult + private func checked( + _ step: String, + operation: () async throws -> T + ) async throws -> T { + do { + return try await operation() + } catch { + XCTFail("\(step): \(error)") + throw error + } + } + + private func testConfig(database: String) -> DriverConnectionConfig { + DriverConnectionConfig( + host: "127.0.0.1", + port: 5_236, + username: "SYSDBA", + password: "test-only", + database: database + ) + } +} + +private struct LiveEnvironment { + let host: String + let port: Int + let username: String + let password: String + + func config(database: String) -> DriverConnectionConfig { + DriverConnectionConfig( + host: host, + port: port, + username: username, + password: password, + database: database + ) + } +} diff --git a/README.md b/README.md index 0748e14a2..a3c4970d5 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ TablePro is the missing fourth: native, multi-database, and open source. | Microsoft SQL Server | Plugin | | MongoDB | Plugin | | Oracle Database | Plugin | +| Dameng DM8 | Plugin | | DuckDB | Plugin | | Beancount | Plugin | | Cassandra / ScyllaDB | Plugin | diff --git a/README.vi.md b/README.vi.md index 6ceb4c254..d14ffaa8f 100644 --- a/README.vi.md +++ b/README.vi.md @@ -81,6 +81,7 @@ TablePro là mảnh thứ tư còn thiếu: native, đa database, và mã nguồ | Microsoft SQL Server | Plugin | | MongoDB | Plugin | | Oracle Database | Plugin | +| Dameng DM8 | Plugin | | DuckDB | Plugin | | Beancount | Plugin | | Cassandra / ScyllaDB | Plugin | diff --git a/README.zh.md b/README.zh.md index 8a2b5bac0..0ef8f78ef 100644 --- a/README.zh.md +++ b/README.zh.md @@ -81,6 +81,7 @@ TablePro 补上缺失的第四类:原生、多数据库、开源。 | Microsoft SQL Server | 插件 | | MongoDB | 插件 | | Oracle Database | 插件 | +| 达梦 DM8 | 插件 | | DuckDB | 插件 | | Cassandra / ScyllaDB | 插件 | | Etcd | 插件 | diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index ea2541e55..d08f5642b 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -11,7 +11,8 @@ extension PluginMetadataRegistry { func registryPluginDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { let ( clickhouseDialect, clickhouseColumnTypes, mssqlDialect, mssqlColumnTypes, - oracleDialect, oracleColumnTypes, duckdbDialect, duckdbColumnTypes, + oracleDialect, oracleColumnTypes, damengDialect, damengCompletions, damengColumnTypes, + duckdbDialect, duckdbColumnTypes, cassandraDialect, cassandraColumnTypes, mongoCompletions, mongoColumnTypes, etcdCompletions, redisCompletions, redisColumnTypes, d1Dialect, d1ColumnTypes ) = registryDefaultIngredients() @@ -513,6 +514,60 @@ extension PluginMetadataRegistry { tagline: String(localized: "Enterprise SQL with PL/SQL") ) )), + ("Dameng", PluginMetadataSnapshot( + displayName: "Dameng DM8", iconName: "cylinder", defaultPort: 5_236, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "dm", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant(id: "plan", label: "Plan", sqlPrefix: "EXPLAIN") + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["dm"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#C60018", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: false, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsDropSchema: true, + supportsRenameColumn: true, + supportsOpportunisticTLS: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Schema", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: ["SYS", "SYSDBA", "SYSAUDITOR", "SYSSSO", "CTISYS"], + fileExtensions: [], + databaseGroupingStrategy: .hierarchicalSchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: damengDialect, + statementCompletions: damengCompletions, + columnTypesByCategory: damengColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [], + category: .relational, + tagline: String(localized: "Enterprise relational database for DM8 deployments") + ) + )), ("ClickHouse", PluginMetadataSnapshot( displayName: "ClickHouse", iconName: "clickhouse-icon", defaultPort: 8_123, requiresAuthentication: true, supportsForeignKeys: false, supportsSchemaEditing: true, diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift index 0fceea3eb..d9f38954c 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift @@ -15,6 +15,9 @@ extension PluginMetadataRegistry { mssqlColumnTypes: [String: [String]], oracleDialect: SQLDialectDescriptor, oracleColumnTypes: [String: [String]], + damengDialect: SQLDialectDescriptor, + damengCompletions: [CompletionEntry], + damengColumnTypes: [String: [String]], duckdbDialect: SQLDialectDescriptor, duckdbColumnTypes: [String: [String]], cassandraDialect: SQLDialectDescriptor, @@ -235,6 +238,73 @@ extension PluginMetadataRegistry { "Other": ["ROWID", "UROWID"] ] + let damengColumnTypes: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "INT", "INTEGER", "BIGINT"], + "Float": ["REAL", "FLOAT", "DOUBLE", "DEC", "DECIMAL", "NUMERIC", "NUMBER"], + "String": ["CHAR", "CHARACTER", "VARCHAR", "VARCHAR2", "TEXT", "CLOB"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BINARY", "VARBINARY", "BLOB", "IMAGE"], + "Boolean": ["BIT", "BOOLEAN"], + "Other": ["ROWID", "INTERVAL"] + ] + let damengDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ORDER", "BY", "GROUP", + "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", "INSERT", "INTO", "VALUES", + "UPDATE", "SET", "DELETE", "MERGE", "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", + "SCHEMA", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", "ADD", "MODIFY", + "COLUMN", "RENAME", "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "IDENTITY", "SEQUENCE", "SYNONYM", "GRANT", "REVOKE", "TRIGGER", "PROCEDURE", "CASE", "WHEN", + "THEN", "ELSE", "END", "UNION", "INTERSECT", "MINUS", "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", + "SAVEPOINT", "EXECUTE", "IMMEDIATE", "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", + "CONNECT", "LEVEL", "START", "WITH", "PRIOR", "ROWNUM", "ROWID", "DUAL" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "LISTAGG", "CONCAT", "SUBSTR", "INSTR", "LENGTH", "LOWER", + "UPPER", "TRIM", "LTRIM", "RTRIM", "REPLACE", "LPAD", "RPAD", "SYSDATE", "CURRENT_DATE", + "CURRENT_TIMESTAMP", "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "EXTRACT", "TO_DATE", "TO_CHAR", + "TO_NUMBER", "TO_TIMESTAMP", "TRUNC", "ROUND", "CEIL", "FLOOR", "ABS", "POWER", "SQRT", "MOD", + "NVL", "NVL2", "COALESCE", "NULLIF", "GREATEST", "LEAST", "CAST", "USER" + ], + dataTypes: Set(damengColumnTypes.values.flatMap { $0 }), + tableOptions: ["TABLESPACE", "STORAGE", "PCTFREE", "INITRANS"], + regexSyntax: .regexpLike, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .offsetFetch, + offsetFetchOrderBy: "ORDER BY 1", + autoLimitStyle: .fetchFirst, + caseSensitivityStyle: .caseFoldFunction + ) + let damengCompletions = [ + CompletionEntry(label: "SELECT", insertText: "SELECT"), + CompletionEntry(label: "SELECT DISTINCT", insertText: "SELECT DISTINCT"), + CompletionEntry(label: "INSERT INTO", insertText: "INSERT INTO"), + CompletionEntry(label: "UPDATE", insertText: "UPDATE"), + CompletionEntry(label: "DELETE FROM", insertText: "DELETE FROM"), + CompletionEntry(label: "MERGE INTO", insertText: "MERGE INTO"), + CompletionEntry(label: "CREATE TABLE", insertText: "CREATE TABLE"), + CompletionEntry(label: "CREATE OR REPLACE VIEW", insertText: "CREATE OR REPLACE VIEW"), + CompletionEntry(label: "CREATE SCHEMA", insertText: "CREATE SCHEMA"), + CompletionEntry(label: "ALTER TABLE", insertText: "ALTER TABLE"), + CompletionEntry(label: "DROP TABLE", insertText: "DROP TABLE"), + CompletionEntry(label: "DROP SCHEMA", insertText: "DROP SCHEMA"), + CompletionEntry(label: "SET SCHEMA", insertText: "SET SCHEMA"), + CompletionEntry(label: "EXPLAIN", insertText: "EXPLAIN"), + CompletionEntry(label: "WHERE", insertText: "WHERE"), + CompletionEntry(label: "GROUP BY", insertText: "GROUP BY"), + CompletionEntry(label: "ORDER BY", insertText: "ORDER BY"), + CompletionEntry(label: "FETCH FIRST", insertText: "FETCH FIRST"), + CompletionEntry(label: "JOIN", insertText: "JOIN"), + CompletionEntry(label: "LEFT JOIN", insertText: "LEFT JOIN"), + CompletionEntry(label: "UNION ALL", insertText: "UNION ALL"), + CompletionEntry(label: "WITH", insertText: "WITH"), + CompletionEntry(label: "CONNECT BY", insertText: "CONNECT BY"), + CompletionEntry(label: "START WITH", insertText: "START WITH"), + CompletionEntry(label: "PARTITION BY", insertText: "PARTITION BY") + ] let duckdbDialect = SQLDialectDescriptor( identifierQuote: "\"", keywords: [ @@ -532,7 +602,8 @@ extension PluginMetadataRegistry { ] return ( clickhouseDialect, clickhouseColumnTypes, mssqlDialect, mssqlColumnTypes, - oracleDialect, oracleColumnTypes, duckdbDialect, duckdbColumnTypes, + oracleDialect, oracleColumnTypes, damengDialect, damengCompletions, damengColumnTypes, + duckdbDialect, duckdbColumnTypes, cassandraDialect, cassandraColumnTypes, mongoCompletions, mongoColumnTypes, etcdCompletions, redisCompletions, redisColumnTypes, d1Dialect, d1ColumnTypes ) diff --git a/TablePro/Core/Services/Query/QueryPlanParser.swift b/TablePro/Core/Services/Query/QueryPlanParser.swift index d2acc3bfb..4a6a3a6d2 100644 --- a/TablePro/Core/Services/Query/QueryPlanParser.swift +++ b/TablePro/Core/Services/Query/QueryPlanParser.swift @@ -345,6 +345,113 @@ struct IndentedTextPlanParser: QueryPlanParser { } } +// MARK: - Dameng Text Parser + +/// Parses DM8 plans whose hierarchy is encoded by spaces after a numeric line prefix. +struct DamengPlanParser: QueryPlanParser { + private struct ParsedLine { + let indent: Int + let operation: String + let details: String? + } + + func parse(rawText: String) -> QueryPlan? { + let lines = rawText.components(separatedBy: .newlines).compactMap(parseLine) + guard !lines.isEmpty else { return nil } + + func buildNodes(from startIndex: Int, parentIndent: Int) -> (nodes: [QueryPlanNode], nextIndex: Int) { + var nodes: [QueryPlanNode] = [] + var index = startIndex + + while index < lines.count { + let line = lines[index] + if line.indent <= parentIndent && index > startIndex { + break + } + + let children: [QueryPlanNode] + let nextIndex: Int + if index + 1 < lines.count, lines[index + 1].indent > line.indent { + let nested = buildNodes(from: index + 1, parentIndent: line.indent) + children = nested.nodes + nextIndex = nested.nextIndex + } else { + children = [] + nextIndex = index + 1 + } + + nodes.append(QueryPlanNode( + operation: line.operation, + relation: nil, + schema: nil, + alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: nil, + estimatedRows: nil, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: nil, + actualRows: nil, + actualLoops: nil, + properties: line.details.map { ["Details": $0] } ?? [:], + children: children + )) + index = nextIndex + } + + return (nodes, index) + } + + let result = buildNodes(from: 0, parentIndent: -1) + let root: QueryPlanNode + if result.nodes.count == 1 { + root = result.nodes[0] + } else { + root = QueryPlanNode( + operation: "Query Plan", + relation: nil, + schema: nil, + alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: nil, + estimatedRows: nil, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: nil, + actualRows: nil, + actualLoops: nil, + properties: [:], + children: result.nodes + ) + } + + return QueryPlan(rootNode: root, planningTime: nil, executionTime: nil, rawText: rawText) + } + + private func parseLine(_ line: String) -> ParsedLine? { + let content = line.drop(while: { $0 == " " || $0 == "\t" }) + let numberEnd = content.prefix(while: { $0.isNumber }).endIndex + guard numberEnd != content.startIndex else { return nil } + + let afterNumber = content[numberEnd...] + let indentation = afterNumber.prefix(while: { $0 == " " || $0 == "\t" }) + let text = afterNumber.dropFirst(indentation.count).trimmingCharacters(in: .whitespaces) + guard text.first == "#" else { return nil } + + let planText = text.dropFirst() + let parts = planText.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let operation = parts[0].trimmingCharacters(in: .whitespaces) + guard !operation.isEmpty else { return nil } + let details = parts.count == 2 ? parts[1].trimmingCharacters(in: .whitespaces) : nil + + return ParsedLine( + indent: indentation.reduce(into: 0) { $0 += $1 == "\t" ? 4 : 1 }, + operation: operation, + details: details?.isEmpty == false ? details : nil + ) + } +} + // MARK: - CockroachDB Parser /// Parses CockroachDB `EXPLAIN` and `EXPLAIN ANALYZE` text output. CockroachDB @@ -497,6 +604,8 @@ enum QueryPlanParserFactory { return SQLitePlanParser() case .clickhouse, .duckdb: return IndentedTextPlanParser() + case .dameng: + return DamengPlanParser() default: return nil } diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 26bb8f3a9..0318102b1 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -390,6 +390,7 @@ scylladb scylla oracle + dm clickhouse ch etcd diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index b253a1678..f6840b324 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -36,6 +36,7 @@ extension DatabaseType { static let redis = DatabaseType(rawValue: "Redis") static let mssql = DatabaseType(rawValue: "SQL Server") static let oracle = DatabaseType(rawValue: "Oracle") + static let dameng = DatabaseType(rawValue: "Dameng") static let clickhouse = DatabaseType(rawValue: "ClickHouse") static let duckdb = DatabaseType(rawValue: "DuckDB") static let cassandra = DatabaseType(rawValue: "Cassandra") @@ -200,6 +201,7 @@ extension DatabaseType { case "SQLite": Color(hex: "0F80CC") case "SQL Server": Color(hex: "CC2927") case "Oracle": Color(hex: "C74634") + case "Dameng": Color(hex: "C60018") case "MongoDB": Color(hex: "00684A") case "Redis": Color(hex: "FF4438") case "ClickHouse": Color(hex: "FFCC01") diff --git a/TableProTests/Core/Plugins/ContainerEntityNameTests.swift b/TableProTests/Core/Plugins/ContainerEntityNameTests.swift index d03f8e077..3405568b2 100644 --- a/TableProTests/Core/Plugins/ContainerEntityNameTests.swift +++ b/TableProTests/Core/Plugins/ContainerEntityNameTests.swift @@ -82,6 +82,14 @@ struct ContainerEntityNameTests { #expect(snapshot(forTypeId: "Oracle")?.schema.defaultSchemaName == "") } + @Test("Dameng switches hierarchical schemas") + func damengContainerIsSchema() { + #expect(PluginManager.shared.containerSwitchTarget(for: .dameng) == .schema) + #expect(PluginManager.shared.containerEntityName(for: .dameng) == "Schema") + #expect(PluginManager.shared.databaseGroupingStrategy(for: .dameng) == .hierarchicalSchema) + #expect(PluginManager.shared.supportsDatabaseTree(for: .dameng) == false) + } + @Test("Engines supporting both prefer databases") func dualModeEnginesPreferDatabases() { #expect(PluginManager.shared.containerSwitchTarget(for: .postgresql) == .database) diff --git a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift index a2e3271e5..09b68b638 100644 --- a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift +++ b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift @@ -308,6 +308,7 @@ struct RegistryAutoLimitStyleTests { func sqlPluginsDeclareStyle() { #expect(defaults["SQL Server"]?.editor.sqlDialect?.autoLimitStyle == .top) #expect(defaults["Oracle"]?.editor.sqlDialect?.autoLimitStyle == .fetchFirst) + #expect(defaults["Dameng"]?.editor.sqlDialect?.autoLimitStyle == .fetchFirst) #expect(defaults["ClickHouse"]?.editor.sqlDialect?.autoLimitStyle == .limit) #expect(defaults["DuckDB"]?.editor.sqlDialect?.autoLimitStyle == .limit) #expect(defaults["Cassandra"]?.editor.sqlDialect?.autoLimitStyle == .limit) diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift index 1d28a1fd3..cc4241932 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift @@ -64,6 +64,31 @@ struct PluginMetadataRegistrySchemaSwitchingTests { #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) } + @Test("Dameng supports schema switching without TLS") + func damengSupportsSchemaSwitching() { + guard let snap = snapshot(forTypeId: "Dameng") else { + Issue.record("Registry default for Dameng missing") + return + } + #expect(snap.capabilities.supportsSchemaSwitching == true) + #expect(snap.capabilities.supportsSSL == false) + #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) + } + + @Test("Dameng publishes DM8 typing suggestions") + func damengPublishesTypingSuggestions() { + guard let snap = snapshot(forTypeId: "Dameng") else { + Issue.record("Registry default for Dameng missing") + return + } + let labels = Set(snap.editor.statementCompletions.map(\.label)) + #expect(labels.isSuperset(of: ["SET SCHEMA", "CREATE SCHEMA", "EXPLAIN", "CONNECT BY"])) + #expect(snap.editor.sqlDialect?.functions.contains("NVL") == true) + #expect(snap.editor.sqlDialect?.dataTypes.contains("VARCHAR2") == true) + #expect(snap.editor.sqlDialect?.dataTypes.contains("XMLTYPE") == false) + #expect(snap.editor.sqlDialect?.functions.contains("DBMS_RANDOM.VALUE") == false) + } + // MARK: - PostgreSQL (regression for the working reference) @Test("PostgreSQL supports schema switching") @@ -99,7 +124,7 @@ struct PluginMetadataRegistrySchemaSwitchingTests { @Test("Quick Switcher allowlist agrees with registry capability flag") func quickSwitcherAllowlistMatchesRegistry() { - let typesThatShouldSupportSchemas = ["PostgreSQL", "Redshift", "Oracle", "SQL Server"] + let typesThatShouldSupportSchemas = ["PostgreSQL", "Redshift", "Oracle", "Dameng", "SQL Server"] for typeId in typesThatShouldSupportSchemas { guard let snap = snapshot(forTypeId: typeId) else { Issue.record("Registry default for \(typeId) missing") diff --git a/TableProTests/Core/Services/Query/DamengPlanParserTests.swift b/TableProTests/Core/Services/Query/DamengPlanParserTests.swift new file mode 100644 index 000000000..f18e0fc6f --- /dev/null +++ b/TableProTests/Core/Services/Query/DamengPlanParserTests.swift @@ -0,0 +1,39 @@ +// +// DamengPlanParserTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +@Suite("Dameng Plan Parser") +struct DamengPlanParserTests { + private let parser = DamengPlanParser() + + @Test("Parses numeric prefixes and indentation as a tree") + func parsesHierarchy() throws { + let text = [ + "1 #NSET2: [1, 1, 108]", + "2 #PRJT2: [1, 1, 108]; exp_num(3)", + "3 #CSCN2: [1, 1, 108]; TABLEPRO_TEST", + ].joined(separator: "\n") + + let plan = try #require(parser.parse(rawText: text)) + + #expect(plan.rootNode.operation == "NSET2") + #expect(plan.rootNode.properties["Details"] == "[1, 1, 108]") + #expect(plan.rootNode.children.first?.operation == "PRJT2") + #expect(plan.rootNode.children.first?.children.first?.operation == "CSCN2") + } + + @Test("Ignores malformed non-plan lines") + func ignoresMalformedLines() { + #expect(parser.parse(rawText: "not a DM8 plan") == nil) + #expect(parser.parse(rawText: "1 missing marker") == nil) + } + + @Test("Factory returns the Dameng parser") + func factoryReturnsDamengParser() { + #expect(QueryPlanParserFactory.parser(for: .dameng) is DamengPlanParser) + } +} diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 0e74cf594..a45c49c83 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -57,6 +57,7 @@ struct DatabaseTypeTests { (DatabaseType.redshift, "Redshift"), (DatabaseType.mssql, "SQL Server"), (DatabaseType.oracle, "Oracle"), + (DatabaseType.dameng, "Dameng"), (DatabaseType.clickhouse, "ClickHouse"), (DatabaseType.duckdb, "DuckDB"), (DatabaseType.cassandra, "Cassandra"), diff --git a/TableProTests/Plugins/DamengParameterBinderTests.swift b/TableProTests/Plugins/DamengParameterBinderTests.swift new file mode 100644 index 000000000..4c4a3f614 --- /dev/null +++ b/TableProTests/Plugins/DamengParameterBinderTests.swift @@ -0,0 +1,58 @@ +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("Dameng parameter binder") +struct DamengParameterBinderTests { + @Test("binds text, null, and binary values") + func bindsSupportedValues() throws { + let sql = try DamengParameterBinder.bind( + query: "INSERT INTO sample VALUES (?, ?, ?)", + parameters: [.text("达梦 O'Brien"), .null, .bytes(Data([0x00, 0xAB, 0xFF]))] + ) + #expect(sql == "INSERT INTO sample VALUES ('达梦 O''Brien', NULL, HEXTORAW('00ABFF'))") + } + + @Test("does not bind placeholders inside SQL syntax regions") + func preservesSyntaxRegions() throws { + let sql = try DamengParameterBinder.bind( + query: """ + SELECT '?', "?", q'[?]', ? + FROM sample + -- ? + /* outer ? /* inner ? */ */ + """, + parameters: [.text("bound")] + ) + #expect(sql.contains("SELECT '?', \"?\", q'[?]', 'bound'")) + #expect(sql.contains("-- ?")) + #expect(sql.contains("/* outer ? /* inner ? */ */")) + } + + @Test("escapes injection-shaped text as one literal") + func escapesInjectionText() throws { + let sql = try DamengParameterBinder.bind( + query: "SELECT ? FROM DUAL", + parameters: [.text("x'; DROP SCHEMA SYSDBA CASCADE; --")] + ) + #expect(sql == "SELECT 'x''; DROP SCHEMA SYSDBA CASCADE; --' FROM DUAL") + } + + @Test("rejects parameter count mismatches") + func rejectsCountMismatches() { + #expect(throws: DamengParameterBindingError.insufficientParameters) { + try DamengParameterBinder.bind(query: "SELECT ?, ?", parameters: [.text("one")]) + } + #expect(throws: DamengParameterBindingError.unusedParameters) { + try DamengParameterBinder.bind(query: "SELECT ?", parameters: [.text("one"), .text("two")]) + } + } + + @Test("rejects embedded null bytes") + func rejectsEmbeddedNull() { + #expect(throws: DamengParameterBindingError.embeddedNull) { + try DamengParameterBinder.bind(query: "SELECT ?", parameters: [.text("a\0b")]) + } + } +} diff --git a/TableProTests/Plugins/DamengStatementClassifierTests.swift b/TableProTests/Plugins/DamengStatementClassifierTests.swift new file mode 100644 index 000000000..391676c22 --- /dev/null +++ b/TableProTests/Plugins/DamengStatementClassifierTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import TablePro + +@Suite("Dameng statement classifier") +struct DamengStatementClassifierTests { + @Test("recognizes row-producing statements after comments") + func recognizesRowStatements() { + #expect(DamengStatementClassifier.expectsRows("/* nested /* note */ */ SELECT 1")) + #expect(DamengStatementClassifier.expectsRows("-- note\nWITH value AS (SELECT 1) SELECT * FROM value")) + #expect(DamengStatementClassifier.expectsRows("EXPLAIN SELECT 1")) + #expect(!DamengStatementClassifier.expectsRows("UPDATE sample SET value = 1")) + } + + @Test("caps a single select on the server") + func capsSingleSelect() { + let capped = DamengStatementClassifier.applyingRowCap(50, to: "SELECT * FROM sample;") + #expect(capped.contains("SELECT * FROM (")) + #expect(capped.contains("SELECT * FROM sample")) + #expect(capped.hasSuffix("WHERE ROWNUM <= 51")) + } + + @Test("does not overflow the maximum row cap") + func maximumCapDoesNotOverflow() { + let capped = DamengStatementClassifier.applyingRowCap(Int.max, to: "SELECT 1") + #expect(capped.hasSuffix("WHERE ROWNUM <= \(Int.max)")) + } + + @Test("does not rewrite multiple statements") + func rejectsMultipleStatements() { + let query = "SELECT 1; DROP TABLE sample" + #expect(DamengStatementClassifier.applyingRowCap(10, to: query) == query) + } + + @Test("keeps semicolons in quoted strings") + func allowsQuotedSemicolons() { + let capped = DamengStatementClassifier.applyingRowCap(10, to: "SELECT ';' FROM DUAL;") + #expect(capped.hasSuffix("WHERE ROWNUM <= 11")) + } + + @Test("keeps semicolons in Dameng alternative quotes") + func allowsAlternativeQuotedSemicolons() { + for query in [ + "SELECT q'[;]' FROM DUAL;", + "SELECT Q'(;)' FROM DUAL;", + "SELECT q'{;}' FROM DUAL;", + "SELECT q'<;>' FROM DUAL;", + "SELECT q'!;!' FROM DUAL;" + ] { + let capped = DamengStatementClassifier.applyingRowCap(10, to: query) + #expect(capped.hasSuffix("WHERE ROWNUM <= 11")) + } + } + + @Test("does not rewrite nonpositive caps or writes") + func ignoresUnsupportedCaps() { + #expect(DamengStatementClassifier.applyingRowCap(nil, to: "SELECT 1") == "SELECT 1") + #expect(DamengStatementClassifier.applyingRowCap(0, to: "SELECT 1") == "SELECT 1") + #expect(DamengStatementClassifier.applyingRowCap(10, to: "DELETE FROM sample") == "DELETE FROM sample") + } +} diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index 3f5428548..3a7b2d0e7 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -26,6 +26,7 @@ TablePro parses standard database connection URLs for importing connections, ope | `sqlserver://` | Microsoft SQL Server (alias) | | `jdbc:sqlserver://` | Microsoft SQL Server (JDBC) | | `oracle://` | Oracle Database | +| `dm://` | Dameng DM8 | | `jdbc:oracle:thin://` | Oracle Database (JDBC thin) | | `cassandra://` | Cassandra | | `cql://` | Cassandra (alias) | @@ -44,7 +45,7 @@ TablePro parses standard database connection URLs for importing connections, ope | `libsql://` | libSQL / Turso | | `surrealdb://` | SurrealDB | -Every scheme above works in the **Import from URL...** sheet. Only these are registered with macOS, so only these can be launched with `open` or a browser link: `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `cockroachdb`, `cockroach`, `mssql`, `sqlserver`, `oracle`, `clickhouse`, `ch`, `cassandra`, `cql`, `scylladb`, `scylla`, `duckdb`, `etcd`, `etcds`, `d1`, `libsql`, `surrealdb`. +Every scheme above works in the **Import from URL...** sheet. Only these are registered with macOS, so only these can be launched with `open` or a browser link: `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `cockroachdb`, `cockroach`, `mssql`, `sqlserver`, `oracle`, `dm`, `clickhouse`, `ch`, `cassandra`, `cql`, `scylladb`, `scylla`, `duckdb`, `etcd`, `etcds`, `d1`, `libsql`, `surrealdb`. Append `+ssh` to any non-file-based scheme (not `sqlite`, `duckdb`, or `beancount`) to use an SSH tunnel: diff --git a/docs/databases/dameng.mdx b/docs/databases/dameng.mdx new file mode 100644 index 000000000..6004c1ddf --- /dev/null +++ b/docs/databases/dameng.mdx @@ -0,0 +1,50 @@ +--- +title: Dameng DM8 +description: Connect to Dameng DM8 with TablePro +--- + +TablePro connects directly to Dameng DM8 over its native wire protocol. The macOS driver is a downloadable plugin and does not require a local DM client installation. + +## Install Plugin + +1. Open **Settings > Plugins > Browse** +2. Find **Dameng Driver** and click **Install** +3. Create a Dameng connection from the connection chooser + +## Connection Settings + +| Field | Default | Notes | +|-------|---------|-------| +| **Host** | `localhost` | DM8 server or tunnel endpoint | +| **Port** | `5236` | Default DM8 listener port | +| **Database** | - | Optional initial schema | +| **Username** | - | DM username, such as `SYSDBA` | +| **Password** | - | Stored in the macOS Keychain | + +The driver supports SSH, SOCKS, and Cloudflare tunnels. Native DM8 TLS is not yet available, so the SSL pane is hidden. Use a trusted tunnel when the database is not on a private network. + +## Connection URL + +```text +dm://user:password@host:5236/schema +``` + +## Features + +- Browse schemas, tables, views, columns, indexes, and foreign keys +- Complete DM8 statements, functions, data types, schemas, tables, views, and columns while typing +- Run SQL with Unicode text, parameterized binary writes, transactions, and row limits +- Switch schemas without reconnecting +- Edit rows with escaped parameter values +- Create and alter tables, indexes, primary keys, and foreign keys +- Inspect table metadata, generated DDL, view definitions, and visual `EXPLAIN` plans + +## Limitations + +- Username and password authentication only +- No native TLS, query cancellation, or trigger editor +- Native binary and off-row LOB reads are not supported by the current transport. Use `RAWTOHEX` for binary + columns and cast `CLOB` values to `VARCHAR` when reading them. +- The Database field selects an initial schema; DM8 database switching is not supported + +If a server value fails to decode, include the DM8 version and column type in a [GitHub issue](https://github.com/TableProApp/TablePro/issues). diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index b484183b6..5f7968963 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -3,7 +3,7 @@ title: Managing Connections description: Create, organize, and switch database connections, with health monitoring and startup commands. --- -TablePro connects to 25 databases through its plugin system. This page covers creating and organizing connections. Driver-specific fields and quirks live on each database's own page. +TablePro connects to 26 databases through its plugin system. This page covers creating and organizing connections. Driver-specific fields and quirks live on each database's own page. ## Supported Databases @@ -17,6 +17,7 @@ TablePro connects to 25 databases through its plugin system. This page covers cr | [PGlite](/databases/pglite) | 5432 | No | No | No | No | No | | [Microsoft SQL Server](/databases/mssql) | 1433 | Yes | Yes | Yes | Yes | Yes | | [Oracle](/databases/oracle) | 1521 | Yes | Yes | Yes | No | Yes | +| [Dameng DM8](/databases/dameng) | 5236 | Yes | No | Yes | No | Yes | | [ClickHouse](/databases/clickhouse) | 8123 | Yes | Yes | Yes | No | Yes | | [Teradata](/databases/teradata) | 1025 | Yes | Yes | Yes | No | Yes | | [Trino](/databases/trino) | 8080 | Yes | Yes | Yes | No | Yes | @@ -80,7 +81,7 @@ Opening a database URL from a browser or terminal skips the form: open "postgresql://user:pass@host:5432/dbname" ``` -TablePro registers these URL schemes with macOS: `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `cockroachdb`, `cockroach`, `mssql`, `sqlserver`, `oracle`, `clickhouse`, `ch`, `cassandra`, `cql`, `scylladb`, `scylla`, `duckdb`, `etcd`, `etcds`, `d1`, `libsql`, and `surrealdb`. +TablePro registers these URL schemes with macOS: `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `cockroachdb`, `cockroach`, `mssql`, `sqlserver`, `oracle`, `dm`, `clickhouse`, `ch`, `cassandra`, `cql`, `scylladb`, `scylla`, `duckdb`, `etcd`, `etcds`, `d1`, `libsql`, and `surrealdb`. What happens on open: diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index db83c5e00..f2482f9e0 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -53,6 +53,7 @@ actor SSHTunnelManager { | TableProCore | Local package in `Packages/` | Shared SwiftPM modules the macOS and iOS apps both use | | Sparkle | SPM, 2.9.0 | Auto-update with EdDSA signing | | OracleNIO | SPM, TablePro fork pinned by revision | Oracle wire protocol for OracleDriverPlugin | +| rust-dameng | Cargo, vendored pinned snapshot | DM8 wire protocol for DamengDriverPlugin | | swift-certificates | SPM, 1.19.0 | Generates the MCP server's self-signed TLS certificate | | Yams | SPM, 5.4.0 | YAML parsing for project folder import | @@ -83,12 +84,13 @@ Five driver plugins ship inside the app bundle, covering nine database types: The app bundle also carries non-driver plugins: CSVInspectorPlugin, export plugins (CSV, JSON, SQL, XLSX, MQL), and import plugins (CSV, JSON, SQL). -The remaining 16 driver plugins are downloaded on demand from the [plugin registry](/development/plugin-registry): +The remaining 17 driver plugins are downloaded on demand from the [plugin registry](/development/plugin-registry): | Plugin | Database types | Connectivity | |--------|---------------|--------------| | MongoDBDriverPlugin | MongoDB | CLibMongoc | | OracleDriverPlugin | Oracle | OracleNIO (SPM fork) | +| DamengDriverPlugin | Dameng DM8 | Rust native wire bridge | | DuckDBDriverPlugin | DuckDB | CDuckDB | | MSSQLDriverPlugin | SQL Server | CFreeTDS | | CassandraDriverPlugin | Cassandra, ScyllaDB | CCassandra | diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index 46697ba9f..b40c4c465 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -106,6 +106,7 @@ The full field list is `RegistryPluginMetadata` in `TablePro/Core/Plugins/Regist |-----------|-----------------| | `plugin-mongodb` | `"MongoDB"` | | `plugin-oracle` | `"Oracle"` | +| `plugin-dameng` | `"Dameng"` | | `plugin-duckdb` | `"DuckDB"` | | `plugin-beancount` | `"Beancount"` | | `plugin-mssql` | `"SQL Server"` | diff --git a/docs/docs.json b/docs/docs.json index 1de1b5d15..eab24b195 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -56,7 +56,8 @@ "databases/cockroachdb", "databases/pglite", "databases/mssql", - "databases/oracle" + "databases/oracle", + "databases/dameng" ] }, { diff --git a/docs/features/plugins.mdx b/docs/features/plugins.mdx index 02fda56a0..4da27a823 100644 --- a/docs/features/plugins.mdx +++ b/docs/features/plugins.mdx @@ -3,7 +3,7 @@ title: Plugins & Themes description: Install database drivers, import and export formats, and themes from the plugin registry, and keep them updated. --- -TablePro loads database drivers, import/export formats, and themes as `.tableplugin` bundles. Five driver plugins ship inside the app and cover 9 databases; 16 more download on demand from the plugin registry. +TablePro loads database drivers, import/export formats, and themes as `.tableplugin` bundles. Five driver plugins ship inside the app and cover 9 databases; 17 more download on demand from the plugin registry. ## Bundled plugins @@ -28,6 +28,7 @@ These install from the registry when you need them: |--------|----------| | MongoDB | MongoDB | | Oracle | Oracle Database | +| Dameng | Dameng DM8 | | Microsoft SQL Server | SQL Server | | DuckDB | DuckDB | | Cassandra | Cassandra, ScyllaDB | diff --git a/docs/images/dameng-external-connection.png b/docs/images/dameng-external-connection.png new file mode 100644 index 000000000..c3636dc4a Binary files /dev/null and b/docs/images/dameng-external-connection.png differ diff --git a/docs/index.mdx b/docs/index.mdx index f0404b934..c62101347 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -3,7 +3,7 @@ title: Introduction description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 20 more. --- -Native macOS client for 25 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. +Native macOS client for 26 databases. Built with SwiftUI and AppKit, no Electron. The download is about 20 MB. TablePro main interface @@ -21,7 +21,7 @@ Native macOS client for 25 databases. Built with SwiftUI and AppKit, no Electron **[Safe Mode](/features/safe-mode)**: 6 per-connection protection levels, from no prompt at all to confirmation dialogs, Touch ID, and read-only. **[Import & Export](/features/import-export)**: CSV, JSON, SQL, XLSX, MQL. Streaming export for large datasets. **[CSV Inspector](/features/csv-inspector)**: Open `.csv` and `.tsv` files directly. Edit cells, insert and delete rows and columns, save in the original dialect. -**[Plugin System](/features/plugins)**: 5 bundled drivers covering 9 databases, plus 16 more drivers installable from the plugin registry. +**[Plugin System](/features/plugins)**: 5 bundled drivers covering 9 databases, plus 17 more drivers installable from the plugin registry. **[iCloud Sync](/features/icloud-sync)**: Connections, groups, tags, settings, SSH profiles, saved queries and folders, favorite tables, and custom AI slash commands sync across Macs. **[Themes](/customization/appearance)**: Light, dark, and custom editor themes. Per-connection color labels. @@ -41,6 +41,7 @@ Native macOS client for 25 databases. Built with SwiftUI and AppKit, no Electron | Redis | 6379 | Built-in | | MongoDB | 27017 | Plugin | | Oracle Database | 1521 | Plugin | +| Dameng DM8 | 5236 | Plugin | | DuckDB | N/A (file-based) | Plugin | | Beancount | N/A (file-based) | Plugin | | Cassandra / ScyllaDB | 9042 | Plugin | diff --git a/project.yml b/project.yml index 3ba6c957f..b4e056a8e 100644 --- a/project.yml +++ b/project.yml @@ -69,6 +69,7 @@ targetTemplates: - path: Plugins/${folder} excludes: - Info.plist + - README.md dependencies: - target: TableProPluginKit embed: false @@ -316,6 +317,8 @@ targets: - Plugins/ClickHouseDriverPlugin/ClickHouseCredentials.swift - Plugins/ClickHouseDriverPlugin/ClickHouseGeneratedColumnClassification.swift - Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift + - Plugins/DamengDriverPlugin/DamengParameterBinder.swift + - Plugins/DamengDriverPlugin/DamengStatementClassifier.swift - Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift - Plugins/DynamoDBDriverPlugin/DynamoDBQueryBuilder.swift - Plugins/DynamoDBDriverPlugin/DynamoDBStatementGenerator.swift @@ -691,6 +694,61 @@ targets: folder: DynamoDBDriverPlugin principalClass: DynamoDBPlugin + DamengDriver: + templates: [DriverPlugin] + templateAttributes: + folder: DamengDriverPlugin + principalClass: DamengPlugin + settings: + base: + HEADER_SEARCH_PATHS: + - $(inherited) + - $(SRCROOT)/Plugins/DamengDriverPlugin/CDameng + LIBRARY_SEARCH_PATHS: + - $(inherited) + - $(SRCROOT)/Libs + OTHER_LDFLAGS: + - $(inherited) + - -force_load + - $(SRCROOT)/Libs/libdameng_bridge.a + - -framework + - Security + - -framework + - CoreFoundation + - -liconv + SWIFT_INCLUDE_PATHS: $(SRCROOT)/Plugins/DamengDriverPlugin/CDameng + + DamengDriverTests: + type: bundle.unit-test + platform: macOS + sources: + - path: Plugins/DamengDriverPlugin + excludes: + - Info.plist + - README.md + - Plugins/DamengDriverPluginTests + dependencies: + - target: TableProPluginKit + settings: + base: + GENERATE_INFOPLIST_FILE: YES + HEADER_SEARCH_PATHS: + - $(inherited) + - $(SRCROOT)/Plugins/DamengDriverPlugin/CDameng + LIBRARY_SEARCH_PATHS: + - $(inherited) + - $(SRCROOT)/Libs + OTHER_LDFLAGS: + - $(inherited) + - -force_load + - $(SRCROOT)/Libs/libdameng_bridge.a + - -framework + - Security + - -framework + - CoreFoundation + - -liconv + SWIFT_INCLUDE_PATHS: $(SRCROOT)/Plugins/DamengDriverPlugin/CDameng + ElasticsearchDriverPlugin: templates: [DriverPlugin] templateAttributes: @@ -831,6 +889,7 @@ aggregateTargets: - CassandraDriver - ClickHouseDriver - CloudflareD1DriverPlugin + - DamengDriver - DuckDBDriver - DynamoDBDriverPlugin - ElasticsearchDriverPlugin @@ -856,6 +915,16 @@ aggregateTargets: scheme: {} schemes: + DamengDriverTests: + build: + targets: + DamengDriverTests: test + buildImplicitDependencies: true + test: + config: Debug + targets: + - name: DamengDriverTests + TablePro: build: targets: diff --git a/scripts/build-dameng.sh b/scripts/build-dameng.sh new file mode 100755 index 000000000..5bc277cd6 --- /dev/null +++ b/scripts/build-dameng.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BRIDGE_DIR="$ROOT_DIR/Native/DamengBridge" +LIBS_DIR="$ROOT_DIR/Libs" +ARCH="${1:-both}" +MACOS_TARGET="14.0" +RUST_TOOLCHAIN="1.91.1" + +if ! rustup toolchain list | grep -q "^${RUST_TOOLCHAIN}"; then + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal +fi + +build_slice() { + local arch="$1" + local target="$2" + local output="$LIBS_DIR/libdameng_bridge_${arch}.a" + + if ! rustup target list --installed --toolchain "$RUST_TOOLCHAIN" | grep -qx "$target"; then + rustup target add "$target" --toolchain "$RUST_TOOLCHAIN" + fi + + env RUSTUP_TOOLCHAIN="$RUST_TOOLCHAIN" MACOSX_DEPLOYMENT_TARGET="$MACOS_TARGET" \ + cargo build \ + --manifest-path "$BRIDGE_DIR/Cargo.toml" \ + --locked \ + --release \ + --target "$target" + + cp "$BRIDGE_DIR/target/$target/release/libtablepro_dameng_bridge.a" "$output" + nm -gU "$output" | grep 'T _tp_dm_connect' > /dev/null +} + +mkdir -p "$LIBS_DIR" + +case "$ARCH" in + arm64) + build_slice arm64 aarch64-apple-darwin + cp "$LIBS_DIR/libdameng_bridge_arm64.a" "$LIBS_DIR/libdameng_bridge.a" + ;; + x86_64) + build_slice x86_64 x86_64-apple-darwin + cp "$LIBS_DIR/libdameng_bridge_x86_64.a" "$LIBS_DIR/libdameng_bridge.a" + ;; + both|universal) + build_slice arm64 aarch64-apple-darwin + build_slice x86_64 x86_64-apple-darwin + lipo -create \ + "$LIBS_DIR/libdameng_bridge_arm64.a" \ + "$LIBS_DIR/libdameng_bridge_x86_64.a" \ + -output "$LIBS_DIR/libdameng_bridge_universal.a" + cp "$LIBS_DIR/libdameng_bridge_universal.a" "$LIBS_DIR/libdameng_bridge.a" + ;; + *) + echo "Usage: $0 [arm64|x86_64|both]" >&2 + exit 1 + ;; +esac + +file "$LIBS_DIR"/libdameng_bridge*.a