From 046c4cc69f3c7af08d706fd9eed7e58eba520f79 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Sun, 26 Jul 2026 17:55:30 +0200 Subject: [PATCH 01/50] chore: move the SQLite driver sources into a standalone crate Relocates the driver from the stackable-odbc-rs workspace, where it was developed alongside the core framework, into its own repository. The sources are moved verbatim; the manifest is rewritten to stand alone, replacing workspace inheritance with explicit values and adding the package metadata the workspace crate never carried. stackable-odbc-core is a path dependency to the sibling checkout: it is private and unpublished. This commit does not compile. The core API has moved ahead of what the workspace pinned, and adapting to it is the next commit, kept separate so that this one is reviewable as the pure relocation it is. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1249 ++++++++ Cargo.toml | 35 + benches/fetch_sqlite.rs | 548 ++++ rust-toolchain.toml | 3 + rustfmt.toml | 6 + src/backend.rs | 895 ++++++ src/backend/execute.rs | 389 +++ src/backend/info.rs | 1627 ++++++++++ src/backend/metadata.rs | 1717 ++++++++++ src/backend/params.rs | 5 + src/backend/types/connect_params.rs | 45 + src/backend/types/mod.rs | 3 + src/escape_dialect.rs | 207 ++ src/ffi_integration_tests.rs | 4581 +++++++++++++++++++++++++++ src/lib.rs | 26 + src/type_conversion.rs | 1017 ++++++ 16 files changed, 12353 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 benches/fetch_sqlite.rs create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 src/backend.rs create mode 100644 src/backend/execute.rs create mode 100644 src/backend/info.rs create mode 100644 src/backend/metadata.rs create mode 100644 src/backend/params.rs create mode 100644 src/backend/types/connect_params.rs create mode 100644 src/backend/types/mod.rs create mode 100644 src/escape_dialect.rs create mode 100644 src/ffi_integration_tests.rs create mode 100644 src/lib.rs create mode 100644 src/type_conversion.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..083f7e2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1249 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +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 = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 6.0.0", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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 = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "odbc-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245cb4fe8236df4fd352ba96075d754233c6509d654d9f1c1482158b7d6c083d" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[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 = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stackable-odbc-core" +version = "0.0.1" +dependencies = [ + "odbc-sys", + "snafu", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "stackable-odbc-sqlite" +version = "0.0.1" +dependencies = [ + "criterion", + "proptest", + "rusqlite", + "snafu", + "stackable-odbc-core", + "tracing", +] + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[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 = "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 = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +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/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a513695 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "stackable-odbc-sqlite" +version = "0.0.1" +edition = "2024" +rust-version = "1.95.0" +authors = ["Stackable GmbH "] +license = "Apache-2.0" +description = "ODBC driver for SQLite, built on the stackable-odbc-core framework." +repository = "https://github.com/stackabletech/stackable-odbc-sqlite" +readme = "README.md" +keywords = ["odbc", "sqlite", "driver", "ffi", "sql"] +categories = ["database", "external-ffi-bindings", "api-bindings"] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +rusqlite = { version = "0.40", features = ["bundled", "column_decltype"] } +snafu = "0.9" +# TODO: switch to a crates.io version dep once stackable-odbc-core is published. +stackable-odbc-core = { path = "../stackable-odbc-core" } +tracing = "0.1" + +[dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } +proptest = "1" + +[lints.clippy] +unwrap_in_result = "deny" +unwrap_used = "deny" +panic = "deny" + +[[bench]] +name = "fetch_sqlite" +harness = false diff --git a/benches/fetch_sqlite.rs b/benches/fetch_sqlite.rs new file mode 100644 index 0000000..4b29758 --- /dev/null +++ b/benches/fetch_sqlite.rs @@ -0,0 +1,548 @@ +//! End-to-end fetch-path benchmarks for the SQLite backend. +//! +//! Goes through the full FFI path: alloc handles -> connect (`:memory:`) -> +//! exec_direct -> fetch -> {bind_col | get_data} -> cleanup. Catches the +//! eager-materialize + per-call clone cost in the SqliteBackend → ColumnValue +//! → write_column_value pipeline. +//! +//! Two workload shapes (see stackable-odbc-core/benches/fetch_throughput.rs for spec): +//! * Shape A — mixed columns (BENCH_ROWS × BENCH_COLS, 50/40/10 i64/str/decimal) +//! * Shape B — 5 columns × BENCH_WIDE_STR_LEN-char strings (BENCH_WIDE_ROWS rows) +//! +//! Three scenarios: +//! * late_binding — SQLFetch + per-cell SQLGetData +//! * bound_columns — SQLBindCol + SQLFetch +//! * repeat_get_data — SQLGetData called BENCH_REPEAT_GET_DATA times per cell +//! +//! Run: +//! cargo bench -p stackable-odbc-sqlite +//! BENCH_ROWS=1000000 cargo bench -p stackable-odbc-sqlite + +use std::ffi::c_void; +use std::hint::black_box; +use std::time::Duration; + +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use stackable_odbc_core::ffi; +use stackable_odbc_core::handles::{ConnectionHandle, as_handle_ref}; +use stackable_odbc_core::types::{CDataType, HandleType, SqlReturn}; +use stackable_odbc_sqlite::SqliteBackend; + +#[derive(Clone, Copy)] +struct BenchConfig { + rows: usize, + cols: usize, + wide_rows: usize, + wide_str_len: usize, + repeat_get_data: usize, +} + +fn env_or(name: &str, default: T) -> T { + std::env::var(name) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +fn bench_config() -> BenchConfig { + BenchConfig { + rows: env_or("BENCH_ROWS", 100_000), + cols: env_or("BENCH_COLS", 20), + wide_rows: env_or("BENCH_WIDE_ROWS", 10_000), + wide_str_len: env_or("BENCH_WIDE_STR_LEN", 1024), + repeat_get_data: env_or("BENCH_REPEAT_GET_DATA", 3), + } +} + +fn configure_for_size(c: Criterion, rows: usize) -> Criterion { + if rows > 250_000 { + c.sample_size(20) + .measurement_time(Duration::from_secs(30)) + .warm_up_time(Duration::from_secs(3)) + } else { + c + } +} + +fn shape_a_split(n_cols: usize) -> (usize, usize, usize) { + let s = (n_cols * 4) / 10; + let d = n_cols / 10; + let i = n_cols - s - d; + debug_assert_eq!(i + s + d, n_cols, "shape_a_split must total n_cols"); + (i, s, d) +} + +/// Allocate env + conn + stmt handles. Caller must call `cleanup` exactly once. +unsafe fn alloc_handles() -> (*mut c_void, *mut c_void, *mut c_void) { + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + let _ = ffi::handle::sql_alloc_handle::( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + let _ = + ffi::handle::sql_alloc_handle::(HandleType::Dbc as i16, env, &mut conn); + let mut stmt: *mut c_void = std::ptr::null_mut(); + let _ = ffi::handle::sql_alloc_handle::( + HandleType::Stmt as i16, + conn, + &mut stmt, + ); + (env, conn, stmt) + } +} + +unsafe fn connect_memory(conn: *mut c_void) -> SqlReturn { + unsafe { + let wide: Vec = "Database=:memory:".encode_utf16().collect(); + ffi::connect::sql_driver_connect_w::( + conn, + std::ptr::null_mut(), + wide.as_ptr(), + wide.len() as i16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + 0, + ) + } +} + +unsafe fn exec_direct(stmt: *mut c_void, sql: &str) -> SqlReturn { + unsafe { + let wide: Vec = sql.encode_utf16().collect(); + ffi::execute::sql_exec_direct_w::(stmt, wide.as_ptr(), wide.len() as i32) + } +} + +unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { + unsafe { + let _ = ffi::handle::sql_free_handle::(HandleType::Stmt as i16, stmt); + let _ = ffi::connect::sql_disconnect::(conn); + let _ = ffi::handle::sql_free_handle::(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::(HandleType::Env as i16, env); + } +} + +/// Run a SQL statement directly on the underlying rusqlite Connection (bypasses +/// the FFI exec path so we can do bulk inserts without the ODBC dispatch). +unsafe fn rusqlite_exec(conn: *mut c_void, sql: &str) { + unsafe { + let h = as_handle_ref::>(conn).expect("valid conn"); + let s = h.connection.as_ref().expect("connected"); + let db = s.conn.lock().expect("lock"); + db.execute_batch(sql).expect("setup sql"); + } +} + +/// Build a `CREATE TABLE` + bulk `INSERT` for Shape A. +fn shape_a_setup_sql(rows: usize, cols: usize) -> String { + let (n_i, n_s, n_d) = shape_a_split(cols); + let mut col_defs: Vec = Vec::with_capacity(cols); + for i in 0..n_i { + col_defs.push(format!("i{i} INTEGER")); + } + for i in 0..n_s { + col_defs.push(format!("s{i} TEXT")); + } + for i in 0..n_d { + col_defs.push(format!("d{i} TEXT")); + } + let create = format!( + "DROP TABLE IF EXISTS bench_a; CREATE TABLE bench_a ({});", + col_defs.join(", ") + ); + + // Generate the row data via SQLite's recursive CTE so we don't need 100k INSERT statements. + let select_exprs: Vec = { + let mut v = Vec::with_capacity(cols); + for i in 0..n_i { + v.push(format!("(n + {i})")); + } + for _ in 0..n_s { + v.push("'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'".into()); + } + for _ in 0..n_d { + v.push("'12345678.90'".into()); + } + v + }; + let insert = format!( + "INSERT INTO bench_a SELECT {} FROM ( + WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < {}) + SELECT n FROM seq + );", + select_exprs.join(", "), + rows, + ); + format!("{create} {insert}") +} + +/// Build a `CREATE TABLE` + bulk `INSERT` for Shape B. +fn shape_b_setup_sql(rows: usize, str_len: usize) -> String { + let s = "x".repeat(str_len); + let create = "DROP TABLE IF EXISTS bench_b; CREATE TABLE bench_b (s0 TEXT, s1 TEXT, s2 TEXT, s3 TEXT, s4 TEXT);".to_string(); + let insert = format!( + "INSERT INTO bench_b SELECT '{s}', '{s}', '{s}', '{s}', '{s}' FROM ( + WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < {rows}) + SELECT n FROM seq + );" + ); + format!("{create} {insert}") +} + +fn shape_a_select(cols: usize) -> String { + let (n_i, n_s, n_d) = shape_a_split(cols); + let mut names: Vec = Vec::with_capacity(cols); + for i in 0..n_i { + names.push(format!("i{i}")); + } + for i in 0..n_s { + names.push(format!("s{i}")); + } + for i in 0..n_d { + names.push(format!("d{i}")); + } + format!("SELECT {} FROM bench_a", names.join(", ")) +} + +fn shape_b_select() -> String { + "SELECT s0, s1, s2, s3, s4 FROM bench_b".to_string() +} + +/// Late-binding drain: SQLFetch + per-cell SQLGetData into a discard buffer. +unsafe fn drain_late_binding(stmt: *mut c_void, n_cols: u16) -> usize { + let mut buf = vec![0u8; 4096]; // generous per-cell scratch buffer + let mut ind: isize = 0; + let mut count = 0usize; + while unsafe { ffi::fetch::sql_fetch::(stmt) } == SqlReturn::SUCCESS { + for col in 1..=n_cols { + let _ = unsafe { + ffi::fetch::sql_get_data::( + stmt, + col, + CDataType::Default as i16, + buf.as_mut_ptr() as *mut c_void, + buf.len() as isize, + &mut ind, + ) + }; + count += 1; + } + } + count +} + +/// One bound column: keeps its own scratch buffer and indicator alive across fetches. +struct BoundColumn { + buf: Vec, + ind: isize, +} + +impl BoundColumn { + fn new() -> Self { + Self { + buf: vec![0u8; 4096], + ind: 0, + } + } +} + +/// Bind every column once; returns the binding storage. Caller must keep the +/// returned Vec alive for as long as the bindings are in effect. +unsafe fn bind_columns(stmt: *mut c_void, n_cols: u16, c_type: CDataType) -> Vec { + unsafe { + let mut bindings: Vec = (0..n_cols).map(|_| BoundColumn::new()).collect(); + for (i, b) in bindings.iter_mut().enumerate() { + let ret = ffi::bind::sql_bind_col::( + stmt, + (i + 1) as u16, + c_type as i16, + b.buf.as_mut_ptr() as *mut c_void, + b.buf.len() as isize, + &mut b.ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "bind_col col {}", i + 1); + } + bindings + } +} + +/// Drain a result set whose columns have already been bound via `bind_columns`. +/// Returns total cells fetched (n_cols × n_rows). +unsafe fn drain_bound_columns(stmt: *mut c_void, n_cols: u16) -> usize { + unsafe { + let mut count = 0usize; + while ffi::fetch::sql_fetch::(stmt) == SqlReturn::SUCCESS { + count += n_cols as usize; + } + count + } +} + +/// Same as `drain_late_binding` but call SQLGetData `repeats` times per cell. +unsafe fn drain_repeat_get_data(stmt: *mut c_void, n_cols: u16, repeats: usize) -> usize { + unsafe { + let mut buf = vec![0u8; 4096]; + let mut ind: isize = 0; + let mut count = 0usize; + while ffi::fetch::sql_fetch::(stmt) == SqlReturn::SUCCESS { + for col in 1..=n_cols { + for _ in 0..repeats { + let _ = ffi::fetch::sql_get_data::( + stmt, + col, + CDataType::Default as i16, + buf.as_mut_ptr() as *mut c_void, + buf.len() as isize, + &mut ind, + ); + count += 1; + } + } + } + count + } +} + +fn bench_shape_a_late_binding(c: &mut Criterion) { + let cfg = bench_config(); + let label = format!("{}x{}", cfg.rows, cfg.cols); + let setup_sql = shape_a_setup_sql(cfg.rows, cfg.cols); + let select_sql = shape_a_select(cfg.cols); + + // Set up the database once, outside the timed block. + let (env, conn, stmt) = unsafe { alloc_handles() }; + assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); + unsafe { + rusqlite_exec(conn, &setup_sql); + } + + let mut group = c.benchmark_group("sqlite/shape_a"); + group.throughput(Throughput::Elements((cfg.rows * cfg.cols) as u64)); + group.bench_function(BenchmarkId::new("late_binding", &label), |b| { + b.iter_batched( + || unsafe { + // Per-iter: close previous cursor and re-execute SELECT to get a fresh cursor. + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &select_sql), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { drain_late_binding(stmt, cfg.cols as u16) }); + }, + BatchSize::PerIteration, + ); + }); + group.finish(); + + unsafe { + cleanup(env, conn, stmt); + } +} + +fn bench_shape_b_late_binding(c: &mut Criterion) { + let cfg = bench_config(); + let label = format!("{}x5_len{}", cfg.wide_rows, cfg.wide_str_len); + let setup_sql = shape_b_setup_sql(cfg.wide_rows, cfg.wide_str_len); + let select_sql = shape_b_select(); + + let (env, conn, stmt) = unsafe { alloc_handles() }; + assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); + unsafe { + rusqlite_exec(conn, &setup_sql); + } + + let mut group = c.benchmark_group("sqlite/shape_b"); + group.throughput(Throughput::Elements((cfg.wide_rows * 5) as u64)); + group.bench_function(BenchmarkId::new("late_binding", &label), |b| { + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &select_sql), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { drain_late_binding(stmt, 5) }); + }, + BatchSize::PerIteration, + ); + }); + group.finish(); + + unsafe { + cleanup(env, conn, stmt); + } +} + +fn bench_shape_a_bound(c: &mut Criterion) { + let cfg = bench_config(); + let label = format!("{}x{}", cfg.rows, cfg.cols); + let setup_sql = shape_a_setup_sql(cfg.rows, cfg.cols); + let select_sql = shape_a_select(cfg.cols); + + let (env, conn, stmt) = unsafe { alloc_handles() }; + assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); + unsafe { + rusqlite_exec(conn, &setup_sql); + } + + // Bind columns once, before the bench loop. Bindings survive SQLCloseCursor + // per ODBC spec, so they remain in effect across all iterations. + assert_eq!( + unsafe { exec_direct(stmt, &select_sql) }, + SqlReturn::SUCCESS + ); + let _bindings = unsafe { bind_columns(stmt, cfg.cols as u16, CDataType::Default) }; + let _ = unsafe { ffi::cursor::sql_close_cursor::(stmt) }; + + let mut group = c.benchmark_group("sqlite/shape_a"); + group.throughput(Throughput::Elements((cfg.rows * cfg.cols) as u64)); + group.bench_function(BenchmarkId::new("bound_columns", &label), |b| { + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &select_sql), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { drain_bound_columns(stmt, cfg.cols as u16) }); + }, + BatchSize::PerIteration, + ); + }); + group.finish(); + + unsafe { + cleanup(env, conn, stmt); + } +} + +fn bench_shape_b_bound(c: &mut Criterion) { + let cfg = bench_config(); + let label = format!("{}x5_len{}", cfg.wide_rows, cfg.wide_str_len); + let setup_sql = shape_b_setup_sql(cfg.wide_rows, cfg.wide_str_len); + let select_sql = shape_b_select(); + + let (env, conn, stmt) = unsafe { alloc_handles() }; + assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); + unsafe { + rusqlite_exec(conn, &setup_sql); + } + + // Bind columns once, before the bench loop. Bindings survive SQLCloseCursor + // per ODBC spec, so they remain in effect across all iterations. + assert_eq!( + unsafe { exec_direct(stmt, &select_sql) }, + SqlReturn::SUCCESS + ); + // Use WChar for the wide-string shape so the bound buffer goes through the UTF-16 path. + let _bindings = unsafe { bind_columns(stmt, 5, CDataType::WChar) }; + let _ = unsafe { ffi::cursor::sql_close_cursor::(stmt) }; + + let mut group = c.benchmark_group("sqlite/shape_b"); + group.throughput(Throughput::Elements((cfg.wide_rows * 5) as u64)); + group.bench_function(BenchmarkId::new("bound_columns", &label), |b| { + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &select_sql), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { drain_bound_columns(stmt, 5) }); + }, + BatchSize::PerIteration, + ); + }); + group.finish(); + + unsafe { + cleanup(env, conn, stmt); + } +} + +fn bench_shape_a_repeat(c: &mut Criterion) { + let cfg = bench_config(); + let label = format!("{}x{}", cfg.rows, cfg.cols); + let setup_sql = shape_a_setup_sql(cfg.rows, cfg.cols); + let select_sql = shape_a_select(cfg.cols); + + let (env, conn, stmt) = unsafe { alloc_handles() }; + assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); + unsafe { + rusqlite_exec(conn, &setup_sql); + } + + let mut group = c.benchmark_group("sqlite/shape_a"); + group.throughput(Throughput::Elements((cfg.rows * cfg.cols) as u64)); + let bench_id = BenchmarkId::new(format!("repeat_get_data_x{}", cfg.repeat_get_data), &label); + group.bench_function(bench_id, |b| { + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &select_sql), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { + drain_repeat_get_data(stmt, cfg.cols as u16, cfg.repeat_get_data) + }); + }, + BatchSize::PerIteration, + ); + }); + group.finish(); + + unsafe { + cleanup(env, conn, stmt); + } +} + +fn bench_shape_b_repeat(c: &mut Criterion) { + let cfg = bench_config(); + let label = format!("{}x5_len{}", cfg.wide_rows, cfg.wide_str_len); + let setup_sql = shape_b_setup_sql(cfg.wide_rows, cfg.wide_str_len); + let select_sql = shape_b_select(); + + let (env, conn, stmt) = unsafe { alloc_handles() }; + assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); + unsafe { + rusqlite_exec(conn, &setup_sql); + } + + let mut group = c.benchmark_group("sqlite/shape_b"); + group.throughput(Throughput::Elements((cfg.wide_rows * 5) as u64)); + let bench_id = BenchmarkId::new(format!("repeat_get_data_x{}", cfg.repeat_get_data), &label); + group.bench_function(bench_id, |b| { + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &select_sql), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { drain_repeat_get_data(stmt, 5, cfg.repeat_get_data) }); + }, + BatchSize::PerIteration, + ); + }); + group.finish(); + + unsafe { + cleanup(env, conn, stmt); + } +} + +fn benches() -> Criterion { + configure_for_size(Criterion::default(), bench_config().rows) +} + +criterion_group! { + name = benches_group; + config = benches(); + targets = + bench_shape_a_late_binding, + bench_shape_b_late_binding, + bench_shape_a_bound, + bench_shape_b_bound, + bench_shape_a_repeat, + bench_shape_b_repeat +} +criterion_main!(benches_group); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f92020c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.95.0" +profile = "default" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..01e2232 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +style_edition = "2024" +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +reorder_impl_items = true +use_field_init_shorthand = true +format_code_in_doc_comments = true diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..707af4a --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,895 @@ +use std::sync::Mutex; + +use snafu::Snafu; +use stackable_odbc_core::{ + backend::Backend, + errors::OdbcError, + types::{ColumnDescriptor, ColumnValue, ConnectParams, ExecuteOutcome, InfoValue, TypeInfoRow}, +}; + +mod execute; +// `pub(crate)` only under `cfg(test)`: the FFI integration tests +// (`ffi_integration_tests.rs`, a sibling of this module under `lib.rs`, not +// a descendant of it) need to reach the `SQLITE_*` capability bitmap +// constants declared in `info`. Non-test callers of this module (`execute`, +// `metadata`) are themselves descendants of `backend` and can already see a +// plain private `mod info` without any visibility widening. +#[cfg(test)] +pub(crate) mod info; +#[cfg(not(test))] +mod info; +mod metadata; +mod params; +mod types; + +/// The SQLite [`Backend`] implementation. +/// +/// A zero-sized type: it carries no state, serving only as the type parameter +/// that [`stackable_odbc_core::forward_ffi!`] instantiates the generic ODBC C ABI entry +/// points with. All per-connection state lives in `SqliteConnection`. +pub struct SqliteBackend; + +pub struct SqliteConnection { + pub conn: Mutex, + /// True while the application has turned autocommit off. `end_tran` reads + /// this to decide whether to open the next transaction after committing. + pub(crate) manual_commit: std::sync::atomic::AtomicBool, +} + +pub struct SqliteStatement { + /// SQL text set by `prepare()`. Present until `execute()` has run. + pub(crate) prepared_sql: Option, + columns: Vec, + rows: Vec>, + cursor: i64, // -1 = before first row + affected_rows: Option, // Some(n) for DML; None for SELECT (use rows.len()) +} + +impl SqliteStatement { + /// Create a new SqliteStatement for a SELECT result set. + pub fn new(columns: Vec, rows: Vec>) -> Self { + Self { + prepared_sql: None, + columns, + rows, + cursor: -1, + affected_rows: None, + } + } + + /// Create a new SqliteStatement representing a completed DML statement + /// (INSERT / UPDATE / DELETE / DDL). `affected_rows` is the count reported + /// by rusqlite's `execute()`. + pub fn dml(affected_rows: usize) -> Self { + Self { + prepared_sql: None, + columns: vec![], + rows: vec![], + cursor: -1, + affected_rows: Some(affected_rows), + } + } + + /// Create a new SqliteStatement that holds prepared SQL but has not yet + /// been executed. Call `execute()` with parameter values to run it. + pub fn prepared(sql: String) -> Self { + Self { + prepared_sql: Some(sql), + columns: vec![], + rows: vec![], + cursor: -1, + affected_rows: None, + } + } +} + +#[derive(Debug, Snafu)] +pub enum SqliteError { + #[snafu(display("SQLite error: {source}"))] + Rusqlite { source: rusqlite::Error }, + #[snafu(display("Missing parameter: {name}"))] + MissingParam { name: String }, + #[snafu(display("{feature} is not implemented"))] + NotImplemented { feature: String }, + #[snafu(display("{message}"))] + General { message: String }, + + // --- Classified variants produced by `map_sqlite_error` --- + #[snafu(display("unable to open database: {message}"))] + ConnectionFailed { message: String }, + #[snafu(display("integrity constraint violation: {message}"))] + ConstraintViolation { message: String }, + #[snafu(display("syntax error or access violation: {message}"))] + SyntaxError { message: String }, + #[snafu(display("table or view not found: {message}"))] + TableNotFound { message: String }, + #[snafu(display("column not found: {message}"))] + ColumnNotFound { message: String }, + #[snafu(display("database is busy: {message}"))] + DatabaseBusy { message: String }, + #[snafu(display("data type mismatch: {message}"))] + DataTypeMismatch { message: String }, + #[snafu(display("numeric value out of range: {message}"))] + NumericOutOfRange { message: String }, +} + +/// Central mapping from `rusqlite` errors to [`SqliteError`]. +/// +/// Every error originating from rusqlite must be routed through this function +/// so that SQLSTATE selection happens in exactly one place. Hand-building an +/// error at the call site silently degrades a specific SQLSTATE to `HY000`. +/// +/// SQLite reports syntax errors, missing tables and missing columns with the +/// same result code (`SQLITE_ERROR`), so those three are distinguished by the +/// message text, which SQLite generates from a fixed set of formats. +pub(crate) fn map_sqlite_error(e: rusqlite::Error) -> SqliteError { + use rusqlite::ErrorCode; + + match e { + rusqlite::Error::SqliteFailure(ref ffi_err, ref msg) => { + let message = msg.clone().unwrap_or_else(|| e.to_string()); + match ffi_err.code { + ErrorCode::ConstraintViolation => SqliteError::ConstraintViolation { message }, + ErrorCode::CannotOpen | ErrorCode::NotADatabase | ErrorCode::PermissionDenied => { + SqliteError::ConnectionFailed { message } + } + ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked => { + SqliteError::DatabaseBusy { message } + } + ErrorCode::TypeMismatch => SqliteError::DataTypeMismatch { message }, + // SQLITE_ERROR covers syntax errors and unresolved names alike. + ErrorCode::Unknown => classify_sqlite_error_message(message), + _ => SqliteError::Rusqlite { source: e }, + } + } + // Failures raised while compiling SQL. rusqlite reports these as a + // distinct variant from `SqliteFailure`, but the classification is the + // same: SQLITE_ERROR with a message that names the failure. + rusqlite::Error::SqlInputError { + ref error, ref msg, .. + } => match error.code { + ErrorCode::ConstraintViolation => SqliteError::ConstraintViolation { + message: msg.clone(), + }, + ErrorCode::Unknown => classify_sqlite_error_message(msg.clone()), + _ => SqliteError::Rusqlite { source: e }, + }, + // Errors rusqlite raises itself, without a SQLite result code. + rusqlite::Error::InvalidColumnName(ref name) => SqliteError::ColumnNotFound { + message: format!("no such column: {name}"), + }, + rusqlite::Error::InvalidColumnType(..) | rusqlite::Error::FromSqlConversionFailure(..) => { + SqliteError::DataTypeMismatch { + message: e.to_string(), + } + } + rusqlite::Error::IntegralValueOutOfRange(..) => SqliteError::NumericOutOfRange { + message: e.to_string(), + }, + other => SqliteError::Rusqlite { source: other }, + } +} + +/// Split a `SQLITE_ERROR` message into the SQLSTATE classes the ODBC spec +/// distinguishes. SQLite's wording for these is stable across versions. +fn classify_sqlite_error_message(message: String) -> SqliteError { + if message.starts_with("no such table") || message.starts_with("no such view") { + SqliteError::TableNotFound { message } + } else if message.starts_with("no such column") { + SqliteError::ColumnNotFound { message } + } else { + SqliteError::SyntaxError { message } + } +} + +impl From for OdbcError { + fn from(e: SqliteError) -> Self { + use stackable_odbc_core::types::SqlState; + + let sqlstate = match &e { + SqliteError::NotImplemented { feature } => { + return OdbcError::NotImplemented { + feature: feature.clone(), + }; + } + SqliteError::ConnectionFailed { .. } => { + SqlState::client_unable_to_establish_connection() + } + SqliteError::ConstraintViolation { .. } => SqlState::integrity_constraint_violation(), + SqliteError::SyntaxError { .. } => SqlState::syntax_error_or_access_violation(), + SqliteError::TableNotFound { .. } => SqlState::base_table_or_view_not_found(), + SqliteError::ColumnNotFound { .. } => SqlState::column_not_found(), + SqliteError::DatabaseBusy { .. } => SqlState::timeout_expired(), + SqliteError::DataTypeMismatch { .. } => { + SqlState::restricted_data_type_attribute_violation() + } + SqliteError::NumericOutOfRange { .. } => SqlState::numeric_value_out_of_range(), + SqliteError::Rusqlite { .. } + | SqliteError::MissingParam { .. } + | SqliteError::General { .. } => SqlState::general_error(), + }; + OdbcError::General { + message: e.to_string(), + sqlstate, + } + } +} + +impl Backend for SqliteBackend { + type Connection = SqliteConnection; + type Error = SqliteError; + type Statement = SqliteStatement; + + fn connect(params: &ConnectParams) -> Result { + let p = types::connect_params::SqliteConnectParams::try_from(params)?; + let conn = rusqlite::Connection::open(p.database()).map_err(map_sqlite_error)?; + Ok(SqliteConnection { + conn: Mutex::new(conn), + manual_commit: std::sync::atomic::AtomicBool::new(false), + }) + } + + fn disconnect(_conn: &mut SqliteConnection) -> Result<(), SqliteError> { + Ok(()) // rusqlite closes on drop + } + + fn browse_connect_attrs() -> &'static [&'static str] { + &["database"] + } + + /// SQLite supports transactions and this driver reports `SQL_TC_DML` for + /// `SQL_TXN_CAPABLE`, so manual-commit mode must actually be honoured. + /// + /// Manual-commit mode is entered by opening a transaction with `BEGIN`; + /// `end_tran` then commits or rolls it back and, while still in + /// manual-commit mode, opens the next one. + fn set_autocommit(conn: &SqliteConnection, enabled: bool) -> Result<(), OdbcError> { + let db = conn.conn.lock().map_err(|e| { + OdbcError::general( + format!("Mutex poisoned: {e}"), + stackable_odbc_core::types::SqlState::general_error(), + ) + })?; + if enabled { + // Returning to autocommit commits any open transaction, per the + // ODBC spec: "Any open transactions on the connection are committed + // when SQL_ATTR_AUTOCOMMIT is set to SQL_AUTOCOMMIT_ON". + if !db.is_autocommit() { + db.execute_batch("COMMIT") + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + } + } else if db.is_autocommit() { + db.execute_batch("BEGIN") + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + } + conn.manual_commit + .store(!enabled, std::sync::atomic::Ordering::Relaxed); + Ok(()) + } + + fn end_tran(conn: &SqliteConnection, commit: bool) -> Result<(), OdbcError> { + let db = conn.conn.lock().map_err(|e| { + OdbcError::general( + format!("Mutex poisoned: {e}"), + stackable_odbc_core::types::SqlState::general_error(), + ) + })?; + // If SQLite is in autocommit mode there is no open transaction to commit/roll back. + if db.is_autocommit() { + return Ok(()); + } + let sql = if commit { "COMMIT" } else { "ROLLBACK" }; + db.execute_batch(sql) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + // Still in manual-commit mode: open the next transaction, otherwise + // subsequent statements would silently autocommit. + if conn + .manual_commit + .load(std::sync::atomic::Ordering::Relaxed) + { + db.execute_batch("BEGIN") + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + } + Ok(()) + } + + // --- Delegations --- + + fn exec_direct(conn: &SqliteConnection, sql: &str) -> Result { + execute::exec_direct(conn, sql) + } + + fn prepare(conn: &SqliteConnection, sql: &str) -> Result { + execute::prepare(conn, sql) + } + + fn execute( + conn: &SqliteConnection, + stmt: &mut SqliteStatement, + params: &[ColumnValue], + ) -> Result { + execute::execute(conn, stmt, params) + } + + fn get_info( + conn: &SqliteConnection, + info_type: stackable_odbc_core::types::InfoType, + ) -> Result { + info::get_info(conn, info_type) + } + + fn get_info_pre_connect( + info_type: stackable_odbc_core::types::InfoType, + ) -> Result { + info::get_info_pre_connect(info_type) + } + + fn get_info_raw( + conn: &SqliteConnection, + info_type: u16, + ) -> Option> { + info::get_info_raw(conn, info_type) + } + + fn get_functions() -> &'static [stackable_odbc_core::function_id::FunctionId] { + info::get_functions() + } + + fn get_type_info() -> &'static [TypeInfoRow] { + info::get_type_info() + } + + fn tables( + conn: &SqliteConnection, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + table_type: Option<&str>, + ) -> Result { + metadata::tables(conn, catalog, schema, table, table_type) + } + + fn columns( + conn: &SqliteConnection, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + column: Option<&str>, + ) -> Result { + metadata::columns(conn, catalog, schema, table, column) + } + + fn primary_keys( + conn: &SqliteConnection, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + ) -> Result { + metadata::primary_keys(conn, catalog, schema, table) + } + + fn foreign_keys( + conn: &SqliteConnection, + pk_catalog: Option<&str>, + pk_schema: Option<&str>, + pk_table: Option<&str>, + fk_catalog: Option<&str>, + fk_schema: Option<&str>, + fk_table: Option<&str>, + ) -> Result { + metadata::foreign_keys( + conn, pk_catalog, pk_schema, pk_table, fk_catalog, fk_schema, fk_table, + ) + } + + fn statistics( + conn: &SqliteConnection, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + unique_only: bool, + ) -> Result { + metadata::statistics(conn, catalog, schema, table, unique_only) + } + + fn special_columns( + conn: &SqliteConnection, + identifier_type: stackable_odbc_core::types::IdentifierType, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + scope: stackable_odbc_core::types::Scope, + nullable: stackable_odbc_core::types::Nullable, + ) -> Result { + metadata::special_columns( + conn, + identifier_type, + catalog, + schema, + table, + scope, + nullable, + ) + } + + /// SQLite's `{fn}`/`{d}`/`{t}`/`{ts}` escape-translation dialect. See + /// `crate::escape_dialect` for the remap table and its justification + /// against the `SQL_*_FUNCTIONS` bitmaps in `backend/info.rs`. + fn escape_dialect() -> stackable_odbc_core::escape::EscapeDialect { + crate::escape_dialect::dialect() + } +} + +#[cfg(test)] +mod tests { + use stackable_odbc_core::{ + backend::StatementBackend, + types::{CDataType, FetchResult, SQL_DRIVER_ODBC_VER_STRING, sql_state}, + }; + + use super::*; + + #[test] + fn connect_to_in_memory_database() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let mut conn = SqliteBackend::connect(¶ms).unwrap(); + SqliteBackend::disconnect(&mut conn).unwrap(); + } + + #[test] + fn connect_to_nonexistent_directory_fails() { + // /nonexistent/path/ doesn't exist, so SQLite can't create the file + let params = ConnectParams::parse("Database=/nonexistent/path/db.sqlite").unwrap(); + let result = SqliteBackend::connect(¶ms); + assert!(result.is_err()); + } + + #[test] + fn prepared_statement_reexecutes_with_fresh_params() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1),(2),(3);") + .unwrap(); + } + + // Prepare a parameterized SELECT once. + let mut stmt = SqliteBackend::prepare(&conn, "SELECT id FROM t WHERE id = ?1").unwrap(); + + // First execute: matching param -> exactly one row. + SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(2)]).unwrap(); + assert!(matches!(stmt.fetch().unwrap(), FetchResult::Row)); + assert!(matches!(stmt.fetch().unwrap(), FetchResult::NoData)); + + // Re-execute the SAME handle with a non-matching param -> no rows. This + // fails if the cached compiled statement leaked the previous binding. + SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(999)]).unwrap(); + assert!(matches!(stmt.fetch().unwrap(), FetchResult::NoData)); + + // And once more with a matching param -> one row again. + SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + assert!(matches!(stmt.fetch().unwrap(), FetchResult::Row)); + assert!(matches!(stmt.fetch().unwrap(), FetchResult::NoData)); + } + + // --- SQLSTATE classification (map_sqlite_error) --- + + /// Run `sql` against a fresh in-memory database and return the SQLSTATE of + /// the resulting error. + fn sqlstate_of(setup: &str, sql: &str) -> String { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + if !setup.is_empty() { + let db = conn.conn.lock().unwrap(); + db.execute_batch(setup).unwrap(); + } + let Err(err) = SqliteBackend::exec_direct(&conn, sql) else { + panic!("statement should have failed: {sql}"); + }; + OdbcError::from(err).sqlstate().as_str().to_string() + } + + #[test] + fn constraint_violation_produces_23000() { + let state = sqlstate_of( + "CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (1);", + "INSERT INTO t VALUES (1)", + ); + assert_eq!(state, sql_state::INTEGRITY_CONSTRAINT_VIOLATION); + } + + #[test] + fn not_null_violation_produces_23000() { + let state = sqlstate_of( + "CREATE TABLE t (id INTEGER NOT NULL);", + "INSERT INTO t VALUES (NULL)", + ); + assert_eq!(state, sql_state::INTEGRITY_CONSTRAINT_VIOLATION); + } + + #[test] + fn syntax_error_produces_42000() { + let state = sqlstate_of("", "SELCT 1"); + assert_eq!(state, sql_state::SYNTAX_ERROR_OR_ACCESS_VIOLATION); + } + + #[test] + fn failed_commit_deferred_constraint_produces_23000() { + // A deferred foreign-key violation surfaces only at COMMIT. end_tran + // must route that rusqlite error through map_sqlite_error, reporting + // 23000 rather than degrading it to a generic HY000. + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child ( + pid INTEGER REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED + );", + ) + .unwrap(); + } + // Manual-commit mode; the FK violation is deferred until COMMIT. + SqliteBackend::set_autocommit(&conn, false).unwrap(); + SqliteBackend::exec_direct(&conn, "INSERT INTO child VALUES (999)").unwrap(); + let Err(err) = SqliteBackend::end_tran(&conn, true) else { + panic!("COMMIT should have failed the deferred foreign-key constraint"); + }; + assert_eq!( + err.sqlstate().as_str(), + sql_state::INTEGRITY_CONSTRAINT_VIOLATION + ); + } + + #[test] + fn missing_table_produces_42s02() { + let state = sqlstate_of("", "SELECT * FROM no_such_table_here"); + assert_eq!(state, sql_state::BASE_TABLE_OR_VIEW_NOT_FOUND); + } + + #[test] + fn missing_column_produces_42s22() { + let state = sqlstate_of("CREATE TABLE t (id INTEGER);", "SELECT nope FROM t"); + assert_eq!(state, sql_state::COLUMN_NOT_FOUND); + } + + #[test] + fn unopenable_database_produces_08001() { + let params = ConnectParams::parse("Database=/nonexistent/path/db.sqlite").unwrap(); + let Err(err) = SqliteBackend::connect(¶ms) else { + panic!("open should have failed"); + }; + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::CLIENT_UNABLE_TO_ESTABLISH_CONNECTION + ); + } + + #[test] + fn unclassified_errors_still_produce_hy000() { + let err = SqliteError::General { + message: "internal invariant".into(), + }; + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::GENERAL_ERROR + ); + } + + #[test] + fn connect_missing_database_param_fails() { + let params = ConnectParams::parse("Driver=SQLite").unwrap(); + let result = SqliteBackend::connect(¶ms); + assert!(result.is_err()); + } + + #[test] + fn exec_direct_returns_rows() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "CREATE TABLE t (id INTEGER, name TEXT); INSERT INTO t VALUES (1, 'hello');", + ) + .unwrap(); + } + let mut stmt = SqliteBackend::exec_direct(&conn, "SELECT id, name FROM t").unwrap(); + assert_eq!(stmt.column_count(), 2); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::I64(1) + ); + assert_eq!( + stmt.get_data(2, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("hello".into()) + ); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + #[test] + fn exec_direct_empty_result() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch("CREATE TABLE t (id INTEGER)").unwrap(); + } + let mut stmt = SqliteBackend::exec_direct(&conn, "SELECT * FROM t").unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + #[test] + fn exec_direct_with_nulls() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES (NULL);") + .unwrap(); + } + let mut stmt = SqliteBackend::exec_direct(&conn, "SELECT v FROM t").unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::Null + ); + } + + #[test] + fn describe_col_returns_metadata() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") + .unwrap(); + } + let stmt = SqliteBackend::exec_direct(&conn, "SELECT id, name FROM t").unwrap(); + let col1 = stmt.describe_col(1).unwrap(); + assert_eq!(col1.name, "id"); + let col2 = stmt.describe_col(2).unwrap(); + assert_eq!(col2.name, "name"); + } + + #[test] + fn row_count_returns_correct_count() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", + ) + .unwrap(); + } + let stmt = SqliteBackend::exec_direct(&conn, "SELECT * FROM t").unwrap(); + assert_eq!(stmt.row_count(), Some(2)); + } + + #[test] + fn get_info_dbms_name() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + let info = + SqliteBackend::get_info(&conn, stackable_odbc_core::types::InfoType::DbmsName).unwrap(); + match info { + InfoValue::String(s) => assert_eq!(s, "SQLite"), + _ => panic!("Expected String InfoValue"), + } + } + + #[test] + fn get_info_driver_odbc_ver() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + let info = + SqliteBackend::get_info(&conn, stackable_odbc_core::types::InfoType::DriverOdbcVer) + .unwrap(); + match info { + InfoValue::String(s) => assert_eq!(s, SQL_DRIVER_ODBC_VER_STRING), + _ => panic!("Expected String InfoValue"), + } + } + + #[test] + fn exec_direct_update_returns_affected_row_count() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "CREATE TABLE t (id INTEGER, v INTEGER); + INSERT INTO t VALUES (1, 10); + INSERT INTO t VALUES (2, 20); + INSERT INTO t VALUES (3, 10);", + ) + .unwrap(); + } + let stmt = SqliteBackend::exec_direct(&conn, "UPDATE t SET v = 99 WHERE v = 10").unwrap(); + assert_eq!(stmt.column_count(), 0); + assert_eq!(stmt.row_count(), Some(2)); // rows 1 and 3 were updated + } + + #[test] + fn exec_direct_delete_returns_affected_row_count() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "CREATE TABLE t (id INTEGER); + INSERT INTO t VALUES (1); + INSERT INTO t VALUES (2); + INSERT INTO t VALUES (3);", + ) + .unwrap(); + } + let stmt = SqliteBackend::exec_direct(&conn, "DELETE FROM t WHERE id > 1").unwrap(); + assert_eq!(stmt.column_count(), 0); + assert_eq!(stmt.row_count(), Some(2)); + + // Confirm only row 1 remains + let mut sel = SqliteBackend::exec_direct(&conn, "SELECT COUNT(*) FROM t").unwrap(); + assert_eq!(sel.fetch().unwrap(), FetchResult::Row); + assert_eq!( + sel.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::I64(1) + ); + } + + #[test] + fn exec_direct_fetch_on_dml_returns_no_data() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch("CREATE TABLE t (id INTEGER)").unwrap(); + } + let mut stmt = SqliteBackend::exec_direct(&conn, "INSERT INTO t VALUES (1)").unwrap(); + // DML results have no rows; fetch must return NoData immediately + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + // --------------------------------------------------------------------------- + // prepare + execute tests + // --------------------------------------------------------------------------- + + #[test] + fn prepare_valid_sql_succeeds() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") + .unwrap(); + } + let stmt = SqliteBackend::prepare(&conn, "SELECT id FROM t WHERE id = ?").unwrap(); + assert_eq!( + stmt.prepared_sql.as_deref(), + Some("SELECT id FROM t WHERE id = ?") + ); + assert_eq!(stmt.column_count(), 0); // not yet executed + } + + #[test] + fn prepare_invalid_sql_returns_error() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + let result = SqliteBackend::prepare(&conn, "NOT VALID SQL %%%"); + assert!(result.is_err()); + } + + #[test] + fn execute_select_with_param() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "CREATE TABLE t (id INTEGER, name TEXT);\ + INSERT INTO t VALUES (1, 'alice');\ + INSERT INTO t VALUES (2, 'bob');", + ) + .unwrap(); + } + let mut stmt = SqliteBackend::prepare(&conn, "SELECT name FROM t WHERE id = ?").unwrap(); + SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + assert_eq!(stmt.column_count(), 1); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("alice".into()) + ); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + #[test] + fn execute_can_be_called_multiple_times() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "CREATE TABLE t (id INTEGER, name TEXT);\ + INSERT INTO t VALUES (1, 'alice');\ + INSERT INTO t VALUES (2, 'bob');", + ) + .unwrap(); + } + let mut stmt = SqliteBackend::prepare(&conn, "SELECT name FROM t WHERE id = ?").unwrap(); + + // First execution + SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("alice".into()) + ); + + // Re-execute with different param + SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(2)]).unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("bob".into()) + ); + } + + #[test] + fn execute_dml_with_param() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") + .unwrap(); + } + let mut stmt = SqliteBackend::prepare(&conn, "INSERT INTO t VALUES (?, ?)").unwrap(); + SqliteBackend::execute( + &conn, + &mut stmt, + &[ColumnValue::I64(42), ColumnValue::String("test".into())], + ) + .unwrap(); + assert_eq!(stmt.row_count(), Some(1)); + + // Verify with exec_direct + let mut q = SqliteBackend::exec_direct(&conn, "SELECT id, name FROM t").unwrap(); + assert_eq!(q.fetch().unwrap(), FetchResult::Row); + assert_eq!( + q.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::I64(42) + ); + assert_eq!( + q.get_data(2, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("test".into()) + ); + } + + #[test] + fn execute_select_with_null_param() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(¶ms).unwrap(); + { + let db = conn.conn.lock().unwrap(); + db.execute_batch( + "CREATE TABLE t (id INTEGER, name TEXT);\ + INSERT INTO t VALUES (1, NULL);", + ) + .unwrap(); + } + let mut stmt = SqliteBackend::prepare(&conn, "SELECT name FROM t WHERE id = ?").unwrap(); + SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(1, CDataType::Default).unwrap().into_owned(), + ColumnValue::Null + ); + } +} diff --git a/src/backend/execute.rs b/src/backend/execute.rs new file mode 100644 index 0000000..37ba6cb --- /dev/null +++ b/src/backend/execute.rs @@ -0,0 +1,389 @@ +//! Statement execution for the SQLite backend: `exec_direct`, `prepare` and +//! `execute` (including inline parameter binding), plus the +//! [`StatementBackend`] implementation. SELECT results are fetched eagerly and +//! streamed back through the shared `stackable-odbc-core` fetch path. + +use stackable_odbc_core::backend::StatementBackend; +use stackable_odbc_core::errors::OdbcError; +use stackable_odbc_core::types::{ + CDataType, ColumnDescriptor, ColumnValue, ExecuteOutcome, FetchResult, +}; + +use super::info::sqlite_bare_type_name; +use super::{SqliteConnection, SqliteError, SqliteStatement, map_sqlite_error}; +use crate::type_conversion::{ + column_value_to_rusqlite, sqlite_declared_type_precision, sqlite_declared_type_scale, + sqlite_type_to_sql_data_type, sqlite_value_to_column_value, +}; + +pub(super) fn exec_direct( + conn: &SqliteConnection, + sql: &str, +) -> Result { + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), + })?; + + // Prepare the statement to inspect column count. + let mut stmt = db.prepare(sql).map_err(map_sqlite_error)?; + + // Statements with no result columns are DML (INSERT/UPDATE/DELETE) or DDL. + // Use execute() to run them and capture the affected-row count. + if stmt.column_count() == 0 { + let n = db.execute(sql, []).map_err(map_sqlite_error)?; + return Ok(SqliteStatement::dml(n)); + } + + // SELECT path: collect column metadata, then eagerly fetch all rows. + // Fully-qualified call to avoid name collision with Backend::columns. + let sqlite_columns = rusqlite::Statement::columns(&stmt); + let columns: Vec = sqlite_columns + .iter() + .enumerate() + .map(|(i, col)| { + let name = stmt + .column_name(i) + .map(|n| n.to_string()) + .unwrap_or_else(|_| "?".to_string()); + let decl = col.decl_type().unwrap_or("TEXT").to_string(); + let sql_type = sqlite_type_to_sql_data_type(&decl); + ColumnDescriptor { + name, + sql_type, + precision: sqlite_declared_type_precision(&decl), + scale: sqlite_declared_type_scale(&decl), + // Spec (SQL_DESC_TYPE_NAME / SQLColumns.TYPE_NAME): both list + // bare examples ("CHAR", "VARCHAR", ...), not declarations, so + // `decl` ("VARCHAR(50)") matches no `SQLGetTypeInfo` row. + // `sqlite_bare_type_name` returns the bare name that does + // (see its doc comment in `backend/info.rs`); the declared + // length is not lost, only moved out of the name; it is still + // carried above via `sqlite_declared_type_precision`. + type_name: sqlite_bare_type_name(sql_type).to_string(), + // Result-set columns are reported as nullable: a prepared + // SELECT exposes no per-column NOT NULL metadata, so the driver + // does not attempt to distinguish non-nullable columns here. + nullable: true, + } + }) + .collect(); + + // Eagerly fetch all rows + let col_count = stmt.column_count(); + let mut rows = Vec::new(); + let mut raw_rows = stmt.query([]).map_err(map_sqlite_error)?; + while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { + let mut row_values = Vec::with_capacity(col_count); + for (i, col) in columns.iter().enumerate() { + let value: rusqlite::types::Value = row.get(i).map_err(map_sqlite_error)?; + row_values.push(sqlite_value_to_column_value(value, col.sql_type)); + } + rows.push(row_values); + } + + Ok(SqliteStatement::new(columns, rows)) +} + +/// Validate and store a SQL statement for later execution via [`Backend::execute`]. +/// +/// The SQL is parsed by rusqlite to detect syntax errors early (at prepare time, +/// matching ODBC semantics). The validated SQL is stored in the returned +/// [`SqliteStatement`]; actual execution is deferred until `execute()` is called. +/// +/// Spec: +pub(super) fn prepare(conn: &SqliteConnection, sql: &str) -> Result { + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), + })?; + // Parse-validate the SQL. `prepare_cached` compiles it into the + // connection's statement cache so `execute()` reuses this compilation + // instead of recompiling. The cached statement is returned to the cache + // when the returned handle drops at the end of this function. + db.prepare_cached(sql).map_err(map_sqlite_error)?; + Ok(SqliteStatement::prepared(sql.to_string())) +} + +/// Execute a previously prepared statement with the given parameter values. +/// +/// `params` corresponds to the values collected from `SQLBindParameter` calls. +/// The statement may be executed multiple times with different parameters. +/// Results (columns + rows or affected-row count) are stored back into `stmt`. +/// +/// Spec: +pub(super) fn execute( + conn: &SqliteConnection, + stmt: &mut SqliteStatement, + params: &[ColumnValue], +) -> Result { + let sql = stmt + .prepared_sql + .clone() + .ok_or_else(|| SqliteError::General { + message: "execute() called on a statement with no prepared SQL".into(), + })?; + + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), + })?; + + let rusqlite_params: Vec = + params.iter().map(column_value_to_rusqlite).collect(); + + let mut prepared = db.prepare_cached(&sql).map_err(map_sqlite_error)?; + + if prepared.column_count() == 0 { + // DML / DDL path + let n = prepared + .execute(rusqlite::params_from_iter(rusqlite_params)) + .map_err(map_sqlite_error)?; + stmt.columns = vec![]; + stmt.rows = vec![]; + stmt.affected_rows = Some(n); + stmt.cursor = -1; + // SQLite has no stored-procedure output parameters. + return Ok(ExecuteOutcome::default()); + } + + // SELECT path + let sqlite_columns = rusqlite::Statement::columns(&prepared); + let columns: Vec = sqlite_columns + .iter() + .enumerate() + .map(|(i, col)| { + let name = prepared + .column_name(i) + .map(|n| n.to_string()) + .unwrap_or_else(|_| "?".to_string()); + let decl = col.decl_type().unwrap_or("TEXT").to_string(); + let sql_type = sqlite_type_to_sql_data_type(&decl); + ColumnDescriptor { + name, + sql_type, + precision: sqlite_declared_type_precision(&decl), + scale: sqlite_declared_type_scale(&decl), + // See the `exec_direct` block above for why this is not `decl`. + type_name: sqlite_bare_type_name(sql_type).to_string(), + nullable: true, + } + }) + .collect(); + + let col_count = prepared.column_count(); + let mut rows = Vec::new(); + let mut raw_rows = prepared + .query(rusqlite::params_from_iter(rusqlite_params)) + .map_err(map_sqlite_error)?; + while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { + let mut row_values = Vec::with_capacity(col_count); + for (i, col) in columns.iter().enumerate() { + let value: rusqlite::types::Value = row.get(i).map_err(map_sqlite_error)?; + row_values.push(sqlite_value_to_column_value(value, col.sql_type)); + } + rows.push(row_values); + } + + stmt.columns = columns; + stmt.rows = rows; + stmt.affected_rows = None; + stmt.cursor = -1; + // SQLite has no stored-procedure output parameters. + Ok(ExecuteOutcome::default()) +} + +impl StatementBackend for SqliteStatement { + fn fetch(&mut self) -> Result { + self.cursor += 1; + if (self.cursor as usize) < self.rows.len() { + Ok(FetchResult::Row) + } else { + Ok(FetchResult::NoData) + } + } + + fn get_data( + &mut self, + col: u16, + _target_type: CDataType, + ) -> Result, OdbcError> { + use stackable_odbc_core::types::SqlState; + if self.cursor < 0 || self.cursor as usize >= self.rows.len() { + return Err(OdbcError::NoResultSet); + } + let col_idx = (col as usize).checked_sub(1).ok_or_else(|| { + OdbcError::general("Column index must be >= 1", SqlState::general_error()) + })?; + let row = &self.rows[self.cursor as usize]; + row.get(col_idx) + .map(std::borrow::Cow::Borrowed) + .ok_or_else(|| { + OdbcError::general( + format!( + "Column index {} out of range (have {} columns)", + col, + row.len() + ), + SqlState::general_error(), + ) + }) + } + + fn column_count(&self) -> u16 { + self.columns.len() as u16 + } + + fn describe_col(&self, col: u16) -> Result { + use stackable_odbc_core::types::SqlState; + let idx = (col as usize).checked_sub(1).ok_or_else(|| { + OdbcError::general("Column index must be >= 1", SqlState::general_error()) + })?; + self.columns.get(idx).cloned().ok_or_else(|| { + OdbcError::general( + format!("Column {} out of range", col), + SqlState::general_error(), + ) + }) + } + + fn row_count(&self) -> Option { + Some(self.affected_rows.unwrap_or(self.rows.len())) + } + + fn close_cursor(&mut self) { + self.cursor = -1; + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + use std::sync::atomic::AtomicBool; + + use stackable_odbc_core::backend::StatementBackend; + use stackable_odbc_core::errors::OdbcError; + use stackable_odbc_core::types::{CDataType, ColumnValue, FetchResult}; + + use super::*; + use crate::backend::{SqliteConnection, SqliteStatement}; + + fn conn_with(schema: &str) -> SqliteConnection { + let c = rusqlite::Connection::open_in_memory().unwrap(); + c.execute_batch(schema).unwrap(); + SqliteConnection { + conn: Mutex::new(c), + manual_commit: AtomicBool::new(false), + } + } + + #[test] + fn exec_direct_select_returns_columns_and_rows() { + let conn = conn_with( + "CREATE TABLE t (id INTEGER, name TEXT); + INSERT INTO t VALUES (1, 'a'), (2, 'b');", + ); + let mut stmt = exec_direct(&conn, "SELECT id, name FROM t ORDER BY id").unwrap(); + + assert_eq!(stmt.column_count(), 2); + assert_eq!(stmt.describe_col(1).unwrap().name, "id"); + assert_eq!(stmt.describe_col(2).unwrap().name, "name"); + assert_eq!(stmt.row_count(), Some(2)); + + assert!(matches!(stmt.fetch().unwrap(), FetchResult::Row)); + assert!(matches!( + stmt.get_data(1, CDataType::SLong).unwrap().as_ref(), + ColumnValue::I64(1) + )); + assert!(matches!( + stmt.get_data(2, CDataType::SLong).unwrap().as_ref(), + ColumnValue::String(s) if s.as_str() == "a" + )); + assert!(matches!(stmt.fetch().unwrap(), FetchResult::Row)); + assert!(matches!(stmt.fetch().unwrap(), FetchResult::NoData)); + } + + #[test] + fn exec_direct_dml_reports_affected_rows() { + let conn = conn_with("CREATE TABLE t (id INTEGER);"); + let stmt = exec_direct(&conn, "INSERT INTO t VALUES (1), (2), (3)").unwrap(); + assert_eq!(stmt.column_count(), 0); + assert_eq!(stmt.row_count(), Some(3)); + } + + #[test] + fn exec_direct_ddl_has_no_columns() { + let conn = conn_with("CREATE TABLE base (id INTEGER);"); + let stmt = exec_direct(&conn, "CREATE TABLE more (x TEXT)").unwrap(); + assert_eq!(stmt.column_count(), 0); + } + + #[test] + fn exec_direct_syntax_error_maps_to_42000() { + let conn = conn_with("CREATE TABLE t (id INTEGER);"); + let err = match exec_direct(&conn, "SELEC bogus FROM t") { + Ok(_) => panic!("expected a syntax error"), + Err(e) => e, + }; + let odbc: OdbcError = err.into(); + assert_eq!(odbc.sqlstate().as_str(), "42000"); + } + + #[test] + fn prepare_rejects_invalid_sql_early() { + let conn = conn_with("CREATE TABLE t (id INTEGER);"); + assert!(prepare(&conn, "INStERT bogus").is_err()); + } + + #[test] + fn prepare_then_execute_inserts_with_params() { + let conn = conn_with("CREATE TABLE t (id INTEGER, name TEXT);"); + let mut stmt = prepare(&conn, "INSERT INTO t (id, name) VALUES (?, ?)").unwrap(); + + execute( + &conn, + &mut stmt, + &[ColumnValue::I64(1), ColumnValue::String("a".into())], + ) + .unwrap(); + assert_eq!(stmt.row_count(), Some(1)); + + // The same prepared statement re-executes with fresh parameters. + execute( + &conn, + &mut stmt, + &[ColumnValue::I64(2), ColumnValue::String("b".into())], + ) + .unwrap(); + + let mut check = exec_direct(&conn, "SELECT COUNT(*) FROM t").unwrap(); + assert!(matches!(check.fetch().unwrap(), FetchResult::Row)); + assert!(matches!( + check.get_data(1, CDataType::SLong).unwrap().as_ref(), + ColumnValue::I64(2) + )); + } + + #[test] + fn execute_without_prepared_sql_errors() { + let conn = conn_with("CREATE TABLE t (id INTEGER);"); + let mut stmt = SqliteStatement::new(vec![], vec![]); + assert!(execute(&conn, &mut stmt, &[]).is_err()); + } + + #[test] + fn get_data_before_fetch_is_no_result_set() { + let conn = conn_with("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);"); + let mut stmt = exec_direct(&conn, "SELECT id FROM t").unwrap(); + // No fetch() yet: the cursor is before the first row. + assert!(matches!( + stmt.get_data(1, CDataType::SLong), + Err(OdbcError::NoResultSet) + )); + } + + #[test] + fn describe_col_out_of_range_errors() { + let conn = conn_with("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);"); + let stmt = exec_direct(&conn, "SELECT id FROM t").unwrap(); + assert!(stmt.describe_col(0).is_err()); + assert!(stmt.describe_col(2).is_err()); + } +} diff --git a/src/backend/info.rs b/src/backend/info.rs new file mode 100644 index 0000000..ddda50d --- /dev/null +++ b/src/backend/info.rs @@ -0,0 +1,1627 @@ +//! `SQLGetInfo`, `SQLGetTypeInfo` and `SQLGetFunctions` support for the SQLite +//! backend: the `get_info` / `get_info_pre_connect` / `get_info_raw` +//! handlers, the exported-function bitmap, the static type-info rows mapping +//! SQLite's storage classes onto ODBC types, and the SQLite capability +//! bitmaps (`SQLITE_*`). + +use stackable_odbc_core::backend::{Backend, common_get_info_raw, default_get_info}; +use stackable_odbc_core::errors::OdbcError; +use stackable_odbc_core::function_id::FunctionId; +use stackable_odbc_core::types::{ + InfoType, InfoValue, MaxPrecision, MaxScale, Nullable, SQL_AF_ALL, SQL_AF_AVG, SQL_AF_COUNT, + SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, SQL_CODE_DATE, + SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, SQL_FN_NUM_SIGN, + SQL_FN_STR_ASCII, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, SQL_FN_STR_LENGTH, + SQL_FN_STR_LTRIM, SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, + SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, SQL_FN_SYS_IFNULL, + SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, + SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, + SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, SQL_NC_LOW, SQL_NUMERIC_FUNCTIONS, SQL_OUTER_JOINS, + SQL_SEARCHABLE, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, + SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, + SQL_SQL92_VALUE_EXPRESSIONS, SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, + SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, + SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, + SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, + SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_READ_COMMITTED, + SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, + TypeInfoRow, catalog_column_size, format_odbc_version, parse_dotted_version, +}; + +use super::SqliteBackend; +use super::SqliteConnection; +use super::SqliteError; +use crate::type_conversion::{ + BLOB_DEFAULT_COLUMN_SIZE, DECIMAL_DEFAULT_COLUMN_SIZE, MAX_FRACTIONAL_SECONDS_PRECISION, + VARCHAR_DEFAULT_COLUMN_SIZE, +}; + +/// ODBC function IDs for functions this driver implements. +/// Used by `SQLGetFunctions` to report supported capabilities. +/// Reference: +static SUPPORTED_FUNCTIONS: &[FunctionId] = &[ + FunctionId::BindCol, + FunctionId::ColAttribute, + FunctionId::Connect, + FunctionId::DescribeCol, + FunctionId::Disconnect, + FunctionId::ExecDirect, + FunctionId::Execute, + FunctionId::Fetch, + FunctionId::FreeStmt, + FunctionId::NumResultCols, + FunctionId::Prepare, + FunctionId::RowCount, + FunctionId::Columns, + FunctionId::DriverConnect, + FunctionId::GetData, + FunctionId::GetFunctions, + FunctionId::GetInfo, + FunctionId::GetTypeInfo, + FunctionId::Tables, + FunctionId::MoreResults, + FunctionId::AllocHandle, + FunctionId::CloseCursor, + FunctionId::FreeHandle, + FunctionId::GetDiagRec, + FunctionId::BindParameter, + FunctionId::NumParams, + FunctionId::EndTran, + FunctionId::PrimaryKeys, + FunctionId::ForeignKeys, + FunctionId::NativeSql, + FunctionId::Cancel, + FunctionId::Statistics, + FunctionId::SpecialColumns, + FunctionId::SetStmtAttr, + FunctionId::GetStmtAttr, + FunctionId::SetConnectAttr, + FunctionId::GetConnectAttr, + FunctionId::GetDiagField, + FunctionId::SetEnvAttr, + FunctionId::GetEnvAttr, + FunctionId::FetchScroll, + FunctionId::Procedures, + FunctionId::ProcedureColumns, + FunctionId::GetCursorName, + FunctionId::SetCursorName, + FunctionId::ColumnPrivileges, + FunctionId::TablePrivileges, + FunctionId::DescribeParam, + // Data-at-execution (fully implemented in stackable-odbc-core) and the remaining + // exported entry points that delegate to a real implementation. Listed so + // the Windows DM 3.x dispatch bitmap has no gaps. + FunctionId::ParamData, + FunctionId::PutData, + FunctionId::BrowseConnect, + FunctionId::BulkOperations, + FunctionId::SetPos, +]; + +/// Static type information for SQLite's type system. +/// SQLite has 5 storage classes; this table maps them onto the fuller set of +/// ODBC SQL types applications expect, including the ANSI/Unicode character +/// variants and the temporal types SQLite stores as text. +// +// Every `column_size` value below is computed via `catalog_column_size` (the +// ODBC "Column Size" appendix formula, evaluated at this data source's +// maximum supported precision/scale) rather than hand-written (see +// `stackable_odbc_core::types::column_size` module docs). +// +// Rows are sorted by DATA_TYPE ascending (as signed i16, so ODBC extension +// types with negative codes sort first), then by TYPE_NAME ascending within +// an equal DATA_TYPE, per the SQLGetTypeInfo spec's "ordered by DATA_TYPE and +// then ... TYPE_NAME" requirement. This invariant is asserted directly by +// `type_info_rows_sorted_by_data_type_then_type_name` below; keep new rows +// in the correct sorted position rather than appending them. +static SQLITE_TYPE_INFO: &[TypeInfoRow] = &[ + // WVARCHAR — sqlite_type_to_sql_data_type maps VARCHAR/CHAR/CHARACTER/ + // NCHAR/NVARCHAR/VARYING CHARACTER/NATIVE CHARACTER/TEXT/CLOB here, and + // it is the CHAR/CLOB/TEXT-affinity fallback too. This is the row that + // actually satisfies the invariant for every text-affinity declared + // type; the SQL_VARCHAR/SQL_CHAR rows further down this list + // exist only for Windows DM/pyodbc ANSI compatibility. + TypeInfoRow { + type_name: "WVARCHAR", + data_type: SqlDataType::EXT_W_VARCHAR, + column_size: catalog_column_size( + SqlDataType::EXT_W_VARCHAR, + MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), + MaxScale(0), + ), + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: Some("max length"), + nullable: Nullable::SqlNullable as i16, + case_sensitive: true, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::EXT_W_VARCHAR.0, + sql_datetime_sub: None, + num_prec_radix: None, + interval_precision: None, + }, + // WCHAR — Unicode counterpart to the CHAR row further down this list, + // included for symmetry per the Windows DM checklist even though + // sqlite_type_to_sql_data_type itself never produces EXT_W_CHAR (declared + // CHAR(n) collapses into the WVARCHAR affinity above, matching real + // SQLite semantics where CHAR(n) is not length-limited). + TypeInfoRow { + type_name: "WCHAR", + data_type: SqlDataType::EXT_W_CHAR, + column_size: catalog_column_size( + SqlDataType::EXT_W_CHAR, + MaxPrecision(WCHAR_COLUMN_SIZE_ROW), + MaxScale(0), + ), + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: Some("length"), + nullable: Nullable::SqlNullable as i16, + case_sensitive: true, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::EXT_W_CHAR.0, + sql_datetime_sub: None, + num_prec_radix: None, + interval_precision: None, + }, + // BIT — sqlite_type_to_sql_data_type maps BOOLEAN/BOOL here. + TypeInfoRow { + type_name: "BIT", + data_type: SqlDataType::EXT_BIT, + column_size: catalog_column_size(SqlDataType::EXT_BIT, MaxPrecision(0), MaxScale(0)), + literal_prefix: None, + literal_suffix: None, + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::EXT_BIT.0, + sql_datetime_sub: None, + num_prec_radix: None, + interval_precision: None, + }, + // TINYINT — sqlite_type_to_sql_data_type maps TINYINT here. + TypeInfoRow { + type_name: "TINYINT", + data_type: SqlDataType::EXT_TINY_INT, + column_size: catalog_column_size(SqlDataType::EXT_TINY_INT, MaxPrecision(0), MaxScale(0)), + literal_prefix: None, + literal_suffix: None, + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: Some(false), + fixed_prec_scale: false, + auto_unique_value: Some(false), + local_type_name: None, + minimum_scale: Some(0), + maximum_scale: Some(0), + sql_data_type: SqlDataType::EXT_TINY_INT.0, + sql_datetime_sub: None, + num_prec_radix: Some(10), + interval_precision: None, + }, + // BIGINT — sqlite_type_to_sql_data_type maps INTEGER/INT/BIGINT/INT8 here + // (and the "INT"-substring affinity fallback), since SQLite integers are + // always 64-bit storage. This is the row an INTEGER column's reported + // type (SQL_BIGINT) actually resolves to. + TypeInfoRow { + type_name: "BIGINT", + data_type: SqlDataType::EXT_BIG_INT, + column_size: catalog_column_size(SqlDataType::EXT_BIG_INT, MaxPrecision(0), MaxScale(0)), + literal_prefix: None, + literal_suffix: None, + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: Some(false), + fixed_prec_scale: false, + auto_unique_value: Some(false), + local_type_name: None, + minimum_scale: Some(0), + maximum_scale: Some(0), + sql_data_type: SqlDataType::EXT_BIG_INT.0, + sql_datetime_sub: None, + num_prec_radix: Some(10), + interval_precision: None, + }, + TypeInfoRow { + type_name: "BLOB", + data_type: SqlDataType::EXT_VAR_BINARY, + column_size: catalog_column_size( + SqlDataType::EXT_VAR_BINARY, + MaxPrecision(BLOB_DEFAULT_COLUMN_SIZE), + MaxScale(0), + ), + literal_prefix: Some("X'"), + literal_suffix: Some("'"), + create_params: Some("max length"), + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::EXT_VAR_BINARY.0, + sql_datetime_sub: None, + num_prec_radix: None, + interval_precision: None, + }, + // SQL_CHAR (1) — ANSI alias. See the SQL_VARCHAR comment further down + // this list; same rationale for why this is a distinct row from the + // WCHAR row above. + TypeInfoRow { + type_name: "CHAR", + data_type: SqlDataType::CHAR, + column_size: catalog_column_size( + SqlDataType::CHAR, + MaxPrecision(CHAR_COLUMN_SIZE_ROW), + MaxScale(0), + ), + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: Some("length"), + nullable: Nullable::SqlNullable as i16, + case_sensitive: true, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::CHAR.0, + sql_datetime_sub: None, + num_prec_radix: None, + interval_precision: None, + }, + // DECIMAL — sqlite_type_to_sql_data_type maps DECIMAL/NUMERIC here, and + // it is also the NUMERIC-affinity fallback for any declared type that + // SQLite's own affinity rules do not otherwise classify. + TypeInfoRow { + type_name: "DECIMAL", + data_type: SqlDataType::DECIMAL, + column_size: catalog_column_size( + SqlDataType::DECIMAL, + MaxPrecision(DECIMAL_DEFAULT_COLUMN_SIZE), + MaxScale(DECIMAL_MAX_SCALE), + ), + literal_prefix: None, + literal_suffix: None, + create_params: Some("precision,scale"), + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: Some(false), + fixed_prec_scale: false, + auto_unique_value: Some(false), + local_type_name: None, + minimum_scale: Some(0), + maximum_scale: Some(DECIMAL_MAX_SCALE), + sql_data_type: SqlDataType::DECIMAL.0, + sql_datetime_sub: None, + num_prec_radix: Some(10), + interval_precision: None, + }, + TypeInfoRow { + type_name: "INTEGER", + data_type: SqlDataType::INTEGER, + column_size: catalog_column_size(SqlDataType::INTEGER, MaxPrecision(0), MaxScale(0)), + literal_prefix: None, + literal_suffix: None, + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: Some(false), + fixed_prec_scale: false, + auto_unique_value: Some(false), + local_type_name: None, + minimum_scale: Some(0), + maximum_scale: Some(0), + sql_data_type: SqlDataType::INTEGER.0, + sql_datetime_sub: None, + num_prec_radix: Some(10), + interval_precision: None, + }, + // SMALLINT — sqlite_type_to_sql_data_type maps SMALLINT/INT2 here. + TypeInfoRow { + type_name: "SMALLINT", + data_type: SqlDataType::SMALLINT, + column_size: catalog_column_size(SqlDataType::SMALLINT, MaxPrecision(0), MaxScale(0)), + literal_prefix: None, + literal_suffix: None, + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: Some(false), + fixed_prec_scale: false, + auto_unique_value: Some(false), + local_type_name: None, + minimum_scale: Some(0), + maximum_scale: Some(0), + sql_data_type: SqlDataType::SMALLINT.0, + sql_datetime_sub: None, + num_prec_radix: Some(10), + interval_precision: None, + }, + TypeInfoRow { + type_name: "REAL", + data_type: SqlDataType::DOUBLE, + column_size: catalog_column_size(SqlDataType::DOUBLE, MaxPrecision(0), MaxScale(0)), + literal_prefix: None, + literal_suffix: None, + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: Some(false), + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::DOUBLE.0, + sql_datetime_sub: None, + num_prec_radix: Some(2), + interval_precision: None, + }, + // TEXT — column_size matches VARCHAR_DEFAULT_COLUMN_SIZE (255), the + // same default `default_precision_for_type` reports for both VARCHAR and + // EXT_W_VARCHAR (see type_conversion.rs). This row and the VARCHAR row + // immediately below both describe SQLite's single, unbounded TEXT + // storage class under the shared ANSI DATA_TYPE=12, so they must report + // the same size. 255 is the value the rest of the driver treats as + // authoritative for this DATA_TYPE (`default_precision_for_type`, and the + // WVARCHAR row below), so both rows use it. + TypeInfoRow { + type_name: "TEXT", + data_type: SqlDataType::VARCHAR, + column_size: catalog_column_size( + SqlDataType::VARCHAR, + MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), + MaxScale(0), + ), + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: Some("max length"), + nullable: Nullable::SqlNullable as i16, + case_sensitive: true, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::VARCHAR.0, + sql_datetime_sub: None, + num_prec_radix: None, + interval_precision: None, + }, + // SQL_VARCHAR (12) — ANSI alias needed for Windows DM / pyodbc type + // conversion (AGENTS.md "Windows Driver Manager compatibility + // checklist"). sqlite_type_to_sql_data_type never actually returns this + // ANSI code (only EXT_W_VARCHAR, see the WVARCHAR row above); this row + // exists purely so SQLGetTypeInfo(SQL_VARCHAR) finds a match. TYPE_NAME + // differs from the TEXT row immediately above (same DATA_TYPE) because + // SQLite itself treats VARCHAR as a recognised alias of TEXT, and the + // spec explicitly allows multiple rows sharing a DATA_TYPE; column_size + // matches the TEXT row above for the same reason (see that row's + // comment). + TypeInfoRow { + type_name: "VARCHAR", + data_type: SqlDataType::VARCHAR, + column_size: catalog_column_size( + SqlDataType::VARCHAR, + MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), + MaxScale(0), + ), + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: Some("max length"), + nullable: Nullable::SqlNullable as i16, + case_sensitive: true, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::VARCHAR.0, + sql_datetime_sub: None, + num_prec_radix: None, + interval_precision: None, + }, + // DATE — sqlite_type_to_sql_data_type maps DATE here. SQLite has no DATE + // literal syntax; a date value is just a quoted ISO-8601 string, hence + // the plain quote prefix/suffix (matching the TEXT row's convention) + // rather than a typed `DATE '...'` literal. + // DATA_TYPE=91 (SQL_TYPE_DATE), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=1 (SQL_CODE_DATE) + TypeInfoRow { + type_name: "DATE", + data_type: SqlDataType::DATE, + column_size: catalog_column_size(SqlDataType::DATE, MaxPrecision(0), MaxScale(0)), // 'YYYY-MM-DD' + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: None, + maximum_scale: None, + sql_data_type: SqlDataType::DATETIME.0, + sql_datetime_sub: Some(SQL_CODE_DATE), + num_prec_radix: None, + interval_precision: None, + }, + // TIME — sqlite_type_to_sql_data_type maps TIME here. SQLite stores time + // values as plain "HH:MM:SS" text with no fractional-seconds field (see + // column_value_to_rusqlite), so scale is fixed at 0. + // DATA_TYPE=92 (SQL_TYPE_TIME), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=2 (SQL_CODE_TIME) + TypeInfoRow { + type_name: "TIME", + data_type: SqlDataType::TIME, + // 'HH:MM:SS': SQLite has no fractional-seconds capability to report + // as a maximum (MAX_FRACTIONAL_SECONDS_PRECISION = 0), so this is + // the plain (scale-0) form of the TIME formula. + column_size: catalog_column_size( + SqlDataType::TIME, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + ), + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: Some(0), + maximum_scale: Some(MAX_FRACTIONAL_SECONDS_PRECISION), + sql_data_type: SqlDataType::DATETIME.0, + sql_datetime_sub: Some(SQL_CODE_TIME), + num_prec_radix: None, + interval_precision: None, + }, + // TIMESTAMP — sqlite_type_to_sql_data_type maps DATETIME/TIMESTAMP here. + // column_size intentionally excludes a fractional-seconds allowance: it + // is computed via catalog_column_size at MAX_FRACTIONAL_SECONDS_PRECISION + // (0), the same constant sqlite_declared_type_precision uses as the + // fallback for an undeclared TIMESTAMP column (see the consistency test + // below), so minimum/maximum scale are reported as fixed at 0 rather + // than claiming precision the column size does not budget for. + // DATA_TYPE=93 (SQL_TYPE_TIMESTAMP), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=3 (SQL_CODE_TIMESTAMP) + TypeInfoRow { + type_name: "TIMESTAMP", + data_type: SqlDataType::TIMESTAMP, + // 'YYYY-MM-DD HH:MM:SS': same no-fractional-capability rationale + // as the TIME row above. + column_size: catalog_column_size( + SqlDataType::TIMESTAMP, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + ), + literal_prefix: Some("'"), + literal_suffix: Some("'"), + create_params: None, + nullable: Nullable::SqlNullable as i16, + case_sensitive: false, + searchable: SQL_SEARCHABLE, + unsigned: None, + fixed_prec_scale: false, + auto_unique_value: None, + local_type_name: None, + minimum_scale: Some(0), + maximum_scale: Some(MAX_FRACTIONAL_SECONDS_PRECISION), + sql_data_type: SqlDataType::DATETIME.0, + sql_datetime_sub: Some(SQL_CODE_TIMESTAMP), + num_prec_radix: None, + interval_precision: None, + }, +]; + +// `CHAR`/`WCHAR`'s "unbounded" sentinel. `VARCHAR`/`DECIMAL`/`BLOB`'s default +// column sizes and `TIME`/`TIMESTAMP`'s maximum fractional-seconds precision +// come directly from `type_conversion.rs`'s `pub(crate)` constants (used +// below via `catalog_column_size`); there is now exactly one copy of each, +// not two kept in sync by a test. +const CHAR_COLUMN_SIZE_ROW: i32 = u16::MAX as i32; +const WCHAR_COLUMN_SIZE_ROW: i32 = u16::MAX as i32; +/// Conventional maximum scale for undeclared DECIMAL/NUMERIC, matching +/// `DECIMAL_DEFAULT_COLUMN_SIZE` (both 38); SQLite imposes no real limit, +/// so precision and scale share the same conventional ceiling. +const DECIMAL_MAX_SCALE: i16 = 38; + +/// All values here are connection-independent (driver-level constants). +/// Extracted so that both the connected and pre-connect paths can use it +/// without duplicating the match. +fn sqlite_get_info(info_type: InfoType) -> Result { + // Driver-specific overrides + match info_type { + InfoType::DriverName => return Ok(InfoValue::String("stackable-odbc-sqlite".into())), + InfoType::DriverVer => { + return Ok(InfoValue::String(stackable_odbc_core::driver_version!())); + } + InfoType::DbmsName => return Ok(InfoValue::String("SQLite".into())), + InfoType::DbmsVer => { + let raw = rusqlite::version(); + // The spec permits appending the data source's own version string + // after the ##.##.#### prefix, which keeps SQLite's native + // spelling visible to anyone reading the value by eye. + return Ok(InfoValue::String(match parse_dotted_version(raw) { + Some((major, minor, release)) => { + format!("{} ({raw})", format_odbc_version(major, minor, release)) + } + None => { + tracing::warn!( + raw, + "could not parse the SQLite version; reporting it verbatim" + ); + raw.to_string() + } + })); + } + InfoType::SchemaUsage => return Ok(InfoValue::U32(0)), // SQLite has no schemas + InfoType::CatalogUsage => return Ok(InfoValue::U32(0)), // SQLite has no catalogs + InfoType::CatalogLocation => return Ok(InfoValue::U16(0)), // catalogs not supported + InfoType::CatalogName => return Ok(InfoValue::String("N".into())), + InfoType::IdentifierCase => return Ok(InfoValue::U16(SQL_IC_MIXED)), + InfoType::NullCollation => return Ok(InfoValue::U16(SQL_NC_LOW)), + InfoType::DefaultTxnIsolation => return Ok(InfoValue::U32(SQL_TXN_SERIALIZABLE)), + InfoType::TransactionIsolationProtocol => { + return Ok(InfoValue::U32( + SQL_TXN_READ_UNCOMMITTED + | SQL_TXN_READ_COMMITTED + | SQL_TXN_REPEATABLE_READ + | SQL_TXN_SERIALIZABLE, + )); + } + // SQL_TXN_CAPABLE is `An SQLUSMALLINT value` per the SQLGetInfo spec, + // not SQLUINTEGER -- found by the info-type conformance test + // (`stackable_odbc_core::conformance`). `SQL_TC_DML` is a small fixed constant + // (1), so the narrowing `as u16` cannot lose information. + InfoType::TransactionCapable => return Ok(InfoValue::U16(SQL_TC_DML as u16)), + // SQL_GD_BLOCK is deliberately not claimed: it means SQLGetData can + // be called for a row in a block cursor after a bulk fetch, but this + // driver has no block cursors to speak of -- `SQLSetStmtAttrW` + // (`stackable-odbc-core/src/ffi/stmt_attr.rs`) rejects any + // SQL_ATTR_ROW_ARRAY_SIZE other than 1, substituting 1 back with + // 01S02, so no application can ever get a multi-row rowset out of + // this driver to begin with. SQL_GD_BOUND, by contrast, genuinely + // holds: `sql_get_data` (`stackable-odbc-core/src/ffi/fetch.rs`) never checks + // `stmt.bindings` before reading a column, so a column bound via + // `SQLBindCol` can still be fetched again through `SQLGetData`. + // Reporting the exact capability set (rather than a blanket 0x0F) is + // what the Windows DM checklist in AGENTS.md requires. + InfoType::GetDataExtensions => { + return Ok(InfoValue::U32( + SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND, + )); + } + _ => {} + } + + // Fall through to shared defaults + default_get_info(info_type, &SqliteBackend::catalog_result_column_widths()).ok_or_else(|| { + SqliteError::NotImplemented { + feature: format!("get_info({info_type:?})"), + } + }) +} + +pub(super) fn get_info( + _conn: &SqliteConnection, + info_type: InfoType, +) -> Result { + sqlite_get_info(info_type) +} + +pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result { + sqlite_get_info(info_type).map_err(Into::into) +} + +/// `SQL_AGGREGATE_FUNCTIONS` — SQLite has every ODBC aggregate, and accepts +/// both `DISTINCT` and `ALL` as set quantifiers. +/// +pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = + SQL_AF_AVG | SQL_AF_COUNT | SQL_AF_MAX | SQL_AF_MIN | SQL_AF_SUM | SQL_AF_DISTINCT | SQL_AF_ALL; + +/// `SQL_SQL92_PREDICATES`. +/// +/// Deliberately absent: quantified comparison (`< ALL` / `< ANY` / `< SOME` +/// all fail to prepare -- SQLite's `ALL`/`ANY` are set quantifiers on +/// compound selects, not comparison quantifiers); the four `MATCH` variants +/// (SQLite's `MATCH` is an FTS extension hook, not the SQL-92 row-matching +/// predicate); `OVERLAPS`; and `UNIQUE`. +/// +pub(crate) const SQLITE_SQL92_PREDICATES: u32 = SQL_SP_EXISTS + | SQL_SP_ISNOTNULL + | SQL_SP_ISNULL + | SQL_SP_LIKE + | SQL_SP_IN + | SQL_SP_BETWEEN + | SQL_SP_COMPARISON; + +/// `SQL_SQL92_RELATIONAL_JOIN_OPERATORS`. +/// +/// `RIGHT OUTER JOIN` and `FULL OUTER JOIN` arrived in SQLite 3.39.0; this +/// build is 3.53.2 and both were confirmed by live query. Absent: +/// `CORRESPONDING` (fails to prepare) and SQL-92 `UNION JOIN`, which SQLite +/// has never had. +/// +pub(crate) const SQLITE_SQL92_JOIN_OPERATORS: u32 = SQL_SRJO_CROSS_JOIN + | SQL_SRJO_EXCEPT_JOIN + | SQL_SRJO_FULL_OUTER_JOIN + | SQL_SRJO_INNER_JOIN + | SQL_SRJO_INTERSECT_JOIN + | SQL_SRJO_LEFT_OUTER_JOIN + | SQL_SRJO_NATURAL_JOIN + | SQL_SRJO_RIGHT_OUTER_JOIN; + +/// `SQL_SQL92_VALUE_EXPRESSIONS` — all four present. +/// +pub(crate) const SQLITE_SQL92_VALUE_EXPRESSIONS: u32 = + SQL_SVE_CASE | SQL_SVE_CAST | SQL_SVE_COALESCE | SQL_SVE_NULLIF; + +/// `SQL_NUMERIC_FUNCTIONS` — only three. +/// +/// `rusqlite`'s `bundled` feature does **not** define +/// `SQLITE_ENABLE_MATH_FUNCTIONS`, so the entire trig/log/power/sqrt set is +/// compiled out and was confirmed absent by probing each one. +/// +/// `SIGN` is the exception worth knowing about: it survives because +/// `sign()` is documented on the *core* functions page, not the math +/// functions page, so it is not gated by that flag. Do not remove it on the +/// assumption that "math functions are off" implies no `sign()`. +/// +/// Deliberately absent despite near-misses: `MOD` (`%` is an operator, not a +/// function, and is integer-only -- `7.5 % 2` yields `1`) and `RAND` +/// (`random()` returns a signed 64-bit integer, not ODBC's float in `[0,1)`, +/// and takes no seed). +/// +pub(crate) const SQLITE_NUMERIC_FUNCTIONS: u32 = + SQL_FN_NUM_ABS | SQL_FN_NUM_SIGN | SQL_FN_NUM_ROUND; + +/// `SQL_STRING_FUNCTIONS` — SQLite equivalents, several under other names: +/// `LCASE` is `lower()`, `UCASE` is `upper()`, `SUBSTRING` is `substr()`, +/// `ASCII` is `unicode()`, `CHAR` is `char()`. +/// +/// `SOUNDEX` is claimed because this build enables `SQLITE_SOUNDEX`, which is +/// **not** the SQLite default -- verified by probe (`soundex('Robert')` gives +/// `R163`). A future `rusqlite` bump could silently drop it, which is why +/// `tests::live_sqlite_supports_sign_soundex_and_octet_length` opens a real +/// in-memory connection and calls it (along with `sign()` and +/// `octet_length()`, the other two counter-intuitive entries in this +/// bitmap) rather than only asserting the constant against its own +/// definition. +/// +/// Deliberately absent: `LOCATE` and `LOCATE_2`, because `instr(haystack, +/// needle)` reverses ODBC's `LOCATE(needle, haystack)` -- claiming it would +/// produce silently wrong answers rather than a clean failure. Also absent: +/// `LEFT`/`RIGHT`/`SPACE`/`INSERT`/`REPEAT`/`DIFFERENCE` (no such function) +/// and the `CHAR_LENGTH`/`CHARACTER_LENGTH`/`BIT_LENGTH`/`POSITION` family, +/// none of which SQLite defines. +/// +pub(crate) const SQLITE_STRING_FUNCTIONS: u32 = SQL_FN_STR_CONCAT + | SQL_FN_STR_LTRIM + | SQL_FN_STR_LENGTH + | SQL_FN_STR_LCASE + | SQL_FN_STR_REPLACE + | SQL_FN_STR_RTRIM + | SQL_FN_STR_SUBSTRING + | SQL_FN_STR_UCASE + | SQL_FN_STR_ASCII + | SQL_FN_STR_CHAR + | SQL_FN_STR_SOUNDEX + | SQL_FN_STR_OCTET_LENGTH; + +/// `SQL_SYSTEM_FUNCTIONS` — only `IFNULL`, which SQLite spells the same way. +/// +/// SQLite has no user concept, so no `USERNAME`; and no scalar +/// database-name function, only the `pragma_database_list` table-valued +/// function, which is not an equivalent. +/// +pub(crate) const SQLITE_SYSTEM_FUNCTIONS: u32 = SQL_FN_SYS_IFNULL; + +/// `SQL_TIMEDATE_FUNCTIONS` — only the current-date/time family. +/// +/// `date()`, `time()` and `datetime()` take no arguments and return the +/// current value, so they are genuine equivalents of `CURDATE`, `CURTIME` and +/// `NOW`, and the three `CURRENT_*` keywords work directly. +/// +/// Everything else is deliberately absent. SQLite has no `year()`, +/// `month()`, `day()`, `quarter()` or `extract()` -- only `strftime()` with a +/// format string, which requires the application to write the format itself +/// and returns a zero-padded *string* rather than an integer. `timediff()` +/// exists but returns a formatted delta string, not a count in a caller-chosen +/// unit, so it is not `TIMESTAMPDIFF`. Date modifiers +/// (`date('2020-01-02', '+1 day')`) are a string argument, not a +/// unit-parameterised function, so they are not `TIMESTAMPADD`. +/// +pub(crate) const SQLITE_TIMEDATE_FUNCTIONS: u32 = SQL_FN_TD_NOW + | SQL_FN_TD_CURDATE + | SQL_FN_TD_CURTIME + | SQL_FN_TD_CURRENT_DATE + | SQL_FN_TD_CURRENT_TIME + | SQL_FN_TD_CURRENT_TIMESTAMP; + +pub(super) fn get_info_raw( + _conn: &SqliteConnection, + info_type: u16, +) -> Option> { + // Capability info types. Each one is a genuine `odbc_sys::InfoType` + // variant (odbc-sys 0.31), but this driver still matches on the raw + // `u16` here rather than the typed `InfoType` in `sqlite_get_info`, + // because `get_info_raw` is the dispatch stage that runs before the + // Driver-Manager-safe default and unconditionally wins for these types + // (see `info_type_default_response` in `stackable-odbc-core/src/ffi/info.rs`). + // Before these arms existed they fell through to stackable-odbc-core's generic + // default of 0, so an application asking what SQLite could fold was + // told "nothing". + // + // These describe SQLite *equivalents*, not literal ODBC escape-sequence + // support in the naive sense: `SQLExecDirectW` + // / `SQLPrepareW` do translate `{fn NAME(...)}` escapes + // (`stackable_odbc_core::escape::translate_escapes`, driven by + // `SqliteBackend::escape_dialect()` -- see `crate::escape_dialect`), so + // `{fn ABS(x)}` becomes `ABS(x)` and succeeds, and the "under other + // names" entries documented above are remapped (`UCASE`->`upper`, + // `LCASE`->`lower`, `SUBSTRING`->`substr`, `ASCII`->`unicode`, plus + // `NOW`->`datetime`, `CURDATE`->`date`, `CURTIME`->`time` from the + // `SQL_TIMEDATE_FUNCTIONS` bitmap below). Names SQLite spells identically + // to ODBC (`ABS`, `ROUND`, `CONCAT`, `LENGTH`, `IFNULL`, `CHAR`, ...) pass + // through unchanged and already worked. Still deliberately untranslated: + // `CURRENT_DATE`/`CURRENT_TIME`/`CURRENT_TIMESTAMP` -- SQLite treats these + // as bare keywords (`SELECT CURRENT_DATE();` is a syntax error), and a + // name-only remap cannot drop the trailing `()` the `{fn ...()}` escape + // always includes; see the `crate::escape_dialect` module doc comment. + // None of this is version-gated -- SQLite's version is fixed at compile + // time by the `bundled` feature. + match info_type { + SQL_AGGREGATE_FUNCTIONS => Some(Ok(InfoValue::U32(SQLITE_AGGREGATE_FUNCTIONS))), + SQL_SQL92_PREDICATES => Some(Ok(InfoValue::U32(SQLITE_SQL92_PREDICATES))), + SQL_SQL92_RELATIONAL_JOIN_OPERATORS => { + Some(Ok(InfoValue::U32(SQLITE_SQL92_JOIN_OPERATORS))) + } + SQL_SQL92_VALUE_EXPRESSIONS => Some(Ok(InfoValue::U32(SQLITE_SQL92_VALUE_EXPRESSIONS))), + SQL_NUMERIC_FUNCTIONS => Some(Ok(InfoValue::U32(SQLITE_NUMERIC_FUNCTIONS))), + SQL_STRING_FUNCTIONS => Some(Ok(InfoValue::U32(SQLITE_STRING_FUNCTIONS))), + SQL_SYSTEM_FUNCTIONS => Some(Ok(InfoValue::U32(SQLITE_SYSTEM_FUNCTIONS))), + SQL_TIMEDATE_FUNCTIONS => Some(Ok(InfoValue::U32(SQLITE_TIMEDATE_FUNCTIONS))), + // SQLite supports LIKE ... ESCAPE, and outer joins (RIGHT and FULL + // since 3.39.0; this build is 3.53.2). + SQL_LIKE_ESCAPE_CLAUSE => Some(Ok(InfoValue::String("Y".into()))), + SQL_OUTER_JOINS => Some(Ok(InfoValue::String("Y".into()))), + _ => common_get_info_raw(info_type).map(Ok), + } +} + +pub(super) fn get_functions() -> &'static [FunctionId] { + SUPPORTED_FUNCTIONS +} + +pub(super) fn get_type_info() -> &'static [TypeInfoRow] { + SQLITE_TYPE_INFO +} + +/// Bare, uppercase data-source-dependent type name for a column of +/// `sql_type`, shared by `SQL_DESC_TYPE_NAME` (`SQLColAttributeW`, via +/// `execute.rs`) and `SQLColumns.TYPE_NAME` (`metadata.rs`), so the two +/// never disagree, and so that name always matches a row in +/// [`SQLITE_TYPE_INFO`] (the same table `SQLGetTypeInfo` returns via +/// [`get_type_info`]) by construction, not merely by a matching test. +/// +/// Spec (`SQL_DESC_TYPE_NAME`): "Data source-dependent data type name; for +/// example, "CHAR", "VARCHAR", "MONEY", "LONG VARBINARY", or "CHAR ( ) FOR +/// BIT DATA"." (`SQLColumns.TYPE_NAME` is worded identically, modulo a typo +/// in the truncated "LONG VARBINAR" example.) Every example in both spec +/// pages is a bare name, not a parameterised declaration. SQLite's declared +/// type string (e.g. `"VARCHAR(50)"`) is returned verbatim by neither of +/// these callers any more; the declared length is still carried, just via +/// `SQL_DESC_PRECISION`/`SQL_DESC_LENGTH`/`COLUMN_SIZE` +/// (`sqlite_declared_type_precision`), not the name. +/// +/// A raw uppercase of the declared type's own base spelling is not used +/// here, because several declared aliases this driver recognises +/// (`INT8`/`INT2`/`DOUBLE PRECISION`/`FLOAT`/`CHARACTER`/`NCHAR`/…) do not +/// themselves appear as a `SQLITE_TYPE_INFO` `TYPE_NAME`; only their +/// canonical spelling does (`BIGINT`/`SMALLINT`/`REAL`/`WVARCHAR`/…, see the +/// row comments above). Looking the canonical name up directly in +/// `SQLITE_TYPE_INFO` by `sql_type` (rather than transcribing that mapping a +/// second time here) means the name returned can never drift from a real row. +/// +/// Returns the empty string (matching `SQL_DESC_TYPE_NAME`'s documented +/// behaviour for an unknown type) if `sql_type` is not one this driver's own +/// `SQLGetTypeInfo` table has a row for; this should not happen for any +/// output of `sqlite_type_to_sql_data_type`, and is guarded by +/// `every_reportable_type_has_a_type_info_row` below, but a fallback avoids a +/// panic if that invariant is ever violated. +/// +/// `SQLITE_TYPE_INFO` has two rows sharing `DATA_TYPE=SqlDataType::VARCHAR` +/// (`"TEXT"` and `"VARCHAR"`, both kept purely for Windows DM/pyodbc ANSI +/// compatibility; see the `WVARCHAR`/`TEXT`/`VARCHAR` row comments above), +/// with no single correct bare name for that DATA_TYPE. That case is +/// rejected explicitly below rather than left to `.find` picking whichever +/// row happens to come first: `sqlite_type_to_sql_data_type` never actually +/// returns the ANSI code (`SqlDataType::VARCHAR`) for any declared type +/// today, so the ambiguity is currently unreachable, but this function no +/// longer depends on that fact staying true to give a correct answer; it +/// would rather report "unknown" than silently guess. +pub(super) fn sqlite_bare_type_name(sql_type: SqlDataType) -> &'static str { + if sql_type == SqlDataType::VARCHAR { + tracing::warn!( + ?sql_type, + "sqlite_bare_type_name: DATA_TYPE=SQL_VARCHAR has more than one \ + SQLGetTypeInfo row (TEXT/VARCHAR) with no single canonical name; \ + reporting SQL_DESC_TYPE_NAME/TYPE_NAME as empty string" + ); + return ""; + } + SQLITE_TYPE_INFO + .iter() + .find(|row| row.data_type == sql_type) + .map(|row| row.type_name) + .unwrap_or_else(|| { + tracing::warn!( + ?sql_type, + "sqlite_bare_type_name: no SQLGetTypeInfo row for this SqlDataType; \ + reporting SQL_DESC_TYPE_NAME/TYPE_NAME as empty string" + ); + "" + }) +} + +#[cfg(test)] +mod tests { + + /// Fixed-size types: the "Column Size" appendix formula for these takes + /// no backend-specific parameter, so the row's value must equal the + /// formula applied to *the row's own* `data_type`. Deriving the expected + /// value from `row.data_type` rather than repeating the table's own + /// arguments is what makes this catch a row built with the wrong + /// `SqlDataType`, the one way two drivers could disagree on a value the + /// spec defines as backend-independent. + /// + /// This replaces a cross-driver test crate that compared the two drivers' + /// tables directly. That crate had to link both drivers into one binary, + /// which duplicates every `extern "system"` ODBC export (see the note in + /// this crate's Cargo.toml), and it pinned expected sizes as literals: + /// the very pattern deriving from the formula exists to remove. + #[test] + fn fixed_size_type_info_rows_use_the_backend_independent_formula() { + // Arguments are ignored by the formula for every type listed here; + // any value proves the point, so use deliberately absurd ones. + const IGNORED_PRECISION: MaxPrecision = MaxPrecision(-1); + const IGNORED_SCALE: MaxScale = MaxScale(-1); + + const BACKEND_INDEPENDENT: &[SqlDataType] = &[ + SqlDataType::EXT_BIT, + SqlDataType::EXT_TINY_INT, + SqlDataType::SMALLINT, + SqlDataType::INTEGER, + SqlDataType::EXT_BIG_INT, + SqlDataType::REAL, + SqlDataType::DOUBLE, + SqlDataType::DATE, + ]; + + for row in SQLITE_TYPE_INFO { + if !BACKEND_INDEPENDENT.contains(&row.data_type) { + continue; + } + let expected = catalog_column_size(row.data_type, IGNORED_PRECISION, IGNORED_SCALE); + assert_eq!( + row.column_size, expected, + "{} (DATA_TYPE {:?}): COLUMN_SIZE is {} but the \ + backend-independent appendix formula for that DATA_TYPE \ + gives {} — the row is built from a different SqlDataType \ + than it reports", + row.type_name, row.data_type, row.column_size, expected + ); + } + } + use super::*; + use stackable_odbc_core::types::{ + DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_CA1_NEXT, + SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, + SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, + SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, + SQL_FN_STR_CHARACTER_LENGTH, SQL_FN_STR_DIFFERENCE, SQL_FN_STR_INSERT, SQL_FN_STR_LEFT, + SQL_FN_STR_LOCATE, SQL_FN_STR_LOCATE_2, SQL_FN_STR_POSITION, SQL_FN_STR_REPEAT, + SQL_FN_STR_RIGHT, SQL_FN_STR_SPACE, SQL_FN_TD_DAYNAME, SQL_FN_TD_DAYOFMONTH, + SQL_FN_TD_EXTRACT, SQL_FN_TD_MONTH, SQL_FN_TD_MONTHNAME, SQL_FN_TD_QUARTER, + SQL_FN_TD_TIMESTAMPADD, SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_YEAR, SQL_GB_NO_RELATION, + SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_INSENSITIVE, + SQL_MAX_CURSOR_NAME_LEN, SQL_NC_LOW, SQL_OIC_CORE, SQL_SC_SQL92_ENTRY, SQL_SO_FORWARD_ONLY, + SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, + SQL_SP_MATCH_UNIQUE_PARTIAL, SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, + SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, + SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, + SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, + SQL_TXN_SERIALIZABLE, SQL_U_UNION, SQL_U_UNION_ALL, + }; + + enum Expected { + Str(&'static str), + U16(u16), + U32(u32), + } + + #[rustfmt::skip] + const EXPECTED: &[(InfoType, Expected)] = &[ + // --- String values --- + (InfoType::DriverName, Expected::Str("stackable-odbc-sqlite")), + (InfoType::DbmsName, Expected::Str("SQLite")), + (InfoType::DriverOdbcVer, Expected::Str(SQL_DRIVER_ODBC_VER_STRING)), + (InfoType::SearchPatternEscape, Expected::Str("\\")), + (InfoType::IdentifierQuoteChar, Expected::Str("\"")), + (InfoType::CatalogTerm, Expected::Str("catalog")), + (InfoType::SchemaTerm, Expected::Str("schema")), + (InfoType::CatalogNameSeparator, Expected::Str(".")), + (InfoType::ColumnAlias, Expected::Str("Y")), + (InfoType::OrderByColumnsInSelect, Expected::Str("N")), + (InfoType::CatalogName, Expected::Str("N")), + (InfoType::DataSourceName, Expected::Str("")), + (InfoType::ServerName, Expected::Str("")), + (InfoType::UserName, Expected::Str("")), + (InfoType::DataSourceReadOnly, Expected::Str("N")), + (InfoType::AccessibleTables, Expected::Str("Y")), + (InfoType::AccessibleProcedures, Expected::Str("N")), + (InfoType::Integrity, Expected::Str("N")), + (InfoType::SpecialCharacters, Expected::Str("")), + (InfoType::XopenCliYear, Expected::Str("1995")), + (InfoType::CollationSeq, Expected::Str("")), + (InfoType::DescribeParameter, Expected::Str("Y")), + // --- U16 values --- + (InfoType::GroupBy, Expected::U16(SQL_GB_NO_RELATION)), + (InfoType::MaxDriverConnections, Expected::U16(0)), + (InfoType::MaxConcurrentActivities, Expected::U16(0)), + (InfoType::ConcatNullBehavior, Expected::U16(0)), + (InfoType::CursorCommitBehaviour, Expected::U16(0)), + (InfoType::IdentifierCase, Expected::U16(SQL_IC_MIXED)), + (InfoType::MaxColumnNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::MaxCursorNameLen, Expected::U16(SQL_MAX_CURSOR_NAME_LEN)), + (InfoType::MaxSchemaNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::MaxCatalogNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::MaxTableNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::NullCollation, Expected::U16(SQL_NC_LOW)), + (InfoType::MaxColumnsInGroupBy, Expected::U16(0)), + (InfoType::MaxColumnsInIndex, Expected::U16(0)), + (InfoType::MaxColumnsInOrderBy, Expected::U16(0)), + (InfoType::MaxColumnsInSelect, Expected::U16(0)), + (InfoType::MaxColumnsInTable, Expected::U16(0)), + (InfoType::MaxTablesInSelect, Expected::U16(0)), + (InfoType::MaxUserNameLen, Expected::U16(0)), + (InfoType::ActiveEnvironments, Expected::U16(0)), + (InfoType::MaxIdentifierLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::CatalogLocation, Expected::U16(0)), + // TransactionCapable is SQLUSMALLINT per spec, not SQLUINTEGER -- see + // the matching comment on its arm in sqlite_get_info. + (InfoType::TransactionCapable, Expected::U16(SQL_TC_DML as u16)), + // --- U32 values --- + // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT -- see + // the matching comment in stackable-odbc-core's default_get_info. + (InfoType::CursorSensitivity, Expected::U32(SQL_INSENSITIVE as u32)), + (InfoType::Subqueries, Expected::U32(SQL_SQ_COMPARISON | SQL_SQ_EXISTS | SQL_SQ_IN | SQL_SQ_QUANTIFIED | SQL_SQ_CORRELATED_SUBQUERIES)), + (InfoType::UnionStatement, Expected::U32(SQL_U_UNION | SQL_U_UNION_ALL)), + (InfoType::DefaultTxnIsolation, Expected::U32(SQL_TXN_SERIALIZABLE)), + (InfoType::ScrollOptions, Expected::U32(SQL_SO_FORWARD_ONLY)), + (InfoType::ConvertFunctions, Expected::U32(SQL_FN_CVT_CAST)), + (InfoType::TransactionIsolationProtocol, Expected::U32(SQL_TXN_READ_UNCOMMITTED | SQL_TXN_READ_COMMITTED | SQL_TXN_REPEATABLE_READ | SQL_TXN_SERIALIZABLE)), + (InfoType::AlterTable, Expected::U32(0)), + (InfoType::MaxIndexSize, Expected::U32(0)), + (InfoType::MaxRowSize, Expected::U32(0)), + (InfoType::MaxStatementLen, Expected::U32(0)), + (InfoType::OuterJoinCapabilities, Expected::U32(0)), + (InfoType::SqlConformance, Expected::U32(SQL_SC_SQL92_ENTRY)), + (InfoType::OdbcInterfaceConformance, Expected::U32(SQL_OIC_CORE)), + (InfoType::AsyncMode, Expected::U32(SQL_AM_NONE)), + (InfoType::AsyncDbcFunctions, Expected::U32(0)), + (InfoType::SchemaUsage, Expected::U32(0)), + (InfoType::CatalogUsage, Expected::U32(0)), + (InfoType::GetDataExtensions, Expected::U32(SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND)), + (InfoType::DynamicCursorAttributes1, Expected::U32(0)), + (InfoType::DynamicCursorAttributes2, Expected::U32(0)), + (InfoType::ForwardOnlyCursorAttributes1, Expected::U32(SQL_CA1_NEXT)), + (InfoType::ForwardOnlyCursorAttributes2, Expected::U32(0)), + (InfoType::KeysetCursorAttributes1, Expected::U32(0)), + (InfoType::KeysetCursorAttributes2, Expected::U32(0)), + (InfoType::StaticCursorAttributes1, Expected::U32(0)), + (InfoType::StaticCursorAttributes2, Expected::U32(0)), + ]; + + #[test] + fn get_info_snapshot() { + for (info_type, expected) in EXPECTED { + let actual = sqlite_get_info(*info_type) + .unwrap_or_else(|e| panic!("get_info returned error for {info_type:?}: {e:?}")); + match (expected, &actual) { + (Expected::Str(s), InfoValue::String(v)) => { + assert_eq!(v.as_str(), *s, "wrong value for {info_type:?}") + } + (Expected::U16(n), InfoValue::U16(v)) => { + assert_eq!(v, n, "wrong value for {info_type:?}") + } + (Expected::U32(n), InfoValue::U32(v)) => { + assert_eq!(v, n, "wrong value for {info_type:?}") + } + _ => panic!("type mismatch for {info_type:?}: got {actual:?}"), + } + } + } + + #[test] + fn dbms_ver_is_well_formed() { + let InfoValue::String(s) = sqlite_get_info(InfoType::DbmsVer).unwrap() else { + panic!("expected String for DbmsVer"); + }; + let prefix = s.split(' ').next().unwrap_or(""); + let parts: Vec<&str> = prefix.split('.').collect(); + assert_eq!( + parts.len(), + 3, + "SQL_DBMS_VER must start with ##.##.####, got {s:?}" + ); + assert!( + parts[0].len() >= 2 && parts[1].len() >= 2 && parts[2].len() >= 4, + "SQL_DBMS_VER field widths wrong: {s:?}" + ); + assert!( + parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())), + "SQL_DBMS_VER prefix must be all digits and dots: {s:?}" + ); + } + + /// SQL_DRIVER_VER is derived from Cargo.toml, so it cannot be asserted + /// against a literal without reintroducing drift between the two. + /// Assert the spec's shape instead. + #[test] + fn driver_ver_is_well_formed() { + let InfoValue::String(v) = sqlite_get_info(InfoType::DriverVer).unwrap() else { + panic!("expected String for DriverVer"); + }; + let parts: Vec<&str> = v.split('.').collect(); + assert_eq!( + parts.len(), + 3, + "SQL_DRIVER_VER must be ##.##.####, got {v:?}" + ); + assert!( + parts[0].len() >= 2 && parts[1].len() >= 2 && parts[2].len() >= 4, + "SQL_DRIVER_VER field widths wrong: {v:?}" + ); + assert!( + parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())), + "SQL_DRIVER_VER must be all digits and dots: {v:?}" + ); + } + + /// Guards `SQLITE_STRING_FUNCTIONS`/`SQLITE_NUMERIC_FUNCTIONS`'s three + /// counter-intuitive claims by actually calling the functions on a live + /// in-memory connection, rather than only asserting the bitmap constant + /// against its own definition (`fixed_size_type_info_rows_use_the_backend_independent_formula`- + /// style tests in this module do that for COLUMN_SIZE; this test is the + /// one that exercises these live): + /// + /// - `sign()` -- survives `SQLITE_ENABLE_MATH_FUNCTIONS` being compiled + /// out of the `bundled` feature because it lives on the *core* + /// functions page, not the math one. + /// - `soundex()` -- exists only because this build enables the + /// non-default `SQLITE_SOUNDEX` compile flag. + /// - `octet_length()` -- claimed as the `SQL_FN_STR_OCTET_LENGTH` + /// equivalent. + /// + /// If a future `rusqlite`/`libsqlite3-sys` bump silently drops one of + /// these compile flags, this test fails with a clear "no such function" + /// error instead of the bitmap silently overclaiming forever. + #[test] + fn live_sqlite_supports_sign_soundex_and_octet_length() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + let sign: i64 = conn + .query_row("SELECT sign(-5)", [], |row| row.get(0)) + .expect("sign() should be available (core function, not gated by SQLITE_ENABLE_MATH_FUNCTIONS)"); + assert_eq!(sign, -1, "sign(-5) should be -1"); + + let soundex: String = conn + .query_row("SELECT soundex('Robert')", [], |row| row.get(0)) + .expect("soundex() should be available (this build enables SQLITE_SOUNDEX)"); + assert_eq!(soundex, "R163", "soundex('Robert') should be R163"); + + let octet_length: i64 = conn + .query_row("SELECT octet_length('abc')", [], |row| row.get(0)) + .expect("octet_length() should be available"); + assert_eq!(octet_length, 3, "octet_length('abc') should be 3"); + } + + #[test] + fn every_reportable_type_has_a_type_info_row() { + // Every type SQLColumns can report (e.g. SQL_BIGINT for an INTEGER + // column) must have a matching SQLGetTypeInfo row; otherwise the + // advertised type is absent from the type list entirely. + // + // This iterates `SQLITE_DECLARED_TYPE_ALIASES` rather than a + // hand-copied list of declared type strings, so it cannot miss a + // *new* alias added to `sqlite_type_to_sql_data_type`'s mapping that + // yields a `SqlDataType` with no row: it is the same table + // `sqlite_type_to_sql_data_type` looks up (not a second transcription + // of it), so a new alias added there is automatically exercised here + // too. + for (decl, expected_ty) in crate::type_conversion::SQLITE_DECLARED_TYPE_ALIASES { + let reported = crate::type_conversion::sqlite_type_to_sql_data_type(decl); + assert_eq!( + reported, *expected_ty, + "SQLITE_DECLARED_TYPE_ALIASES entry for {decl:?} does not match what \ + sqlite_type_to_sql_data_type actually returns for it" + ); + assert!( + SQLITE_TYPE_INFO.iter().any(|row| row.data_type == reported), + "declared type {decl:?} is reported as {reported:?}, \ + which has no SQLGetTypeInfo row" + ); + } + + // Residual gap: `SQLITE_DECLARED_TYPE_ALIASES` only covers spellings + // this driver recognises explicitly. A declared type outside that + // table falls back to `sqlite_affinity`'s five substring rules + // instead (see that function's doc comment); its only possible + // outputs are already covered by the loop above, so this pins a few + // concrete affinity-fallback inputs rather than re-deriving that + // closed output set. + for decl in [ + "MADE UP TYPE", + "", + "UNSIGNED BIG INT", + "NATIVE CHARACTER(70)", + ] { + let reported = crate::type_conversion::sqlite_type_to_sql_data_type(decl); + assert!( + SQLITE_TYPE_INFO.iter().any(|row| row.data_type == reported), + "declared type {decl:?} is reported as {reported:?}, \ + which has no SQLGetTypeInfo row" + ); + } + } + + #[test] + fn sqlite_bare_type_name_rejects_the_ambiguous_ansi_varchar_data_type() { + // `SqlDataType::VARCHAR` (the ANSI code, 12) has two + // `SQLITE_TYPE_INFO` rows ("TEXT" and "VARCHAR") and no single right + // answer. `sqlite_type_to_sql_data_type` never actually produces this + // value (only `EXT_W_VARCHAR`, see the WVARCHAR row's comment), so + // this case is unreachable in practice; pin here that it fails safe + // (empty string) rather than silently picking whichever row table + // order happens to put first. + assert_eq!(sqlite_bare_type_name(SqlDataType::VARCHAR), ""); + } + + #[test] + fn sqlite_bare_type_name_matches_a_type_info_row() { + // The invariant this task establishes: SQL_DESC_TYPE_NAME + // (`execute.rs`) and SQLColumns.TYPE_NAME (`metadata.rs`) both call + // `sqlite_bare_type_name`, so pin here that its result always + // matches a row's TYPE_NAME for that same DATA_TYPE, for every + // declared-type alias this driver recognises, including ones whose + // own spelling (e.g. "INT8", "DOUBLE PRECISION") differs from the + // canonical `SQLGetTypeInfo` name it must resolve to ("BIGINT", "REAL"). + for (decl, _) in crate::type_conversion::SQLITE_DECLARED_TYPE_ALIASES { + let sql_type = crate::type_conversion::sqlite_type_to_sql_data_type(decl); + let name = sqlite_bare_type_name(sql_type); + assert!( + SQLITE_TYPE_INFO + .iter() + .any(|row| row.type_name == name && row.data_type == sql_type), + "sqlite_bare_type_name({sql_type:?}) (for declared type {decl:?}) returned \ + {name:?}, which is not a matching SQLGetTypeInfo row" + ); + } + } + + #[test] + fn every_type_info_row_is_reachable_via_sqlite_bare_type_name() { + // Inverse of `every_reportable_type_has_a_type_info_row` above: that + // test guards that every declared-type alias maps to *some* row; + // this one guards the opposite direction: that every + // `SQLITE_TYPE_INFO` row's TYPE_NAME can actually be *produced* by + // `sqlite_bare_type_name` for some `SqlDataType`, not merely + // advertised in the catalog. A row that fails this check is a + // half-truth: an application enumerating SQLGetTypeInfo sees a type + // advertised that no real column can ever be reported under. + // + // Exceptions: "TEXT" and "VARCHAR" both share + // DATA_TYPE=SqlDataType::VARCHAR (the ANSI code, 12), kept purely + // for Windows DM/pyodbc ANSI compatibility (see their own row + // comments in `SQLITE_TYPE_INFO` above). + // `sqlite_type_to_sql_data_type` never actually returns the ANSI + // code for any declared type (only `EXT_W_VARCHAR`, see the + // WVARCHAR row), and `sqlite_bare_type_name` deliberately refuses to + // guess which of the two ambiguous rows is "correct" for that + // DATA_TYPE (see + // `sqlite_bare_type_name_rejects_the_ambiguous_ansi_varchar_data_type` + // below), so neither name is ever produced by the function. + const DM_COMPAT_ONLY: &[&str] = &["TEXT", "VARCHAR"]; + + for row in SQLITE_TYPE_INFO { + if DM_COMPAT_ONLY.contains(&row.type_name) { + continue; + } + let produced = sqlite_bare_type_name(row.data_type); + assert_eq!( + produced, row.type_name, + "SQLITE_TYPE_INFO row {:?} (DATA_TYPE={:?}) is not reachable via \ + sqlite_bare_type_name (got {produced:?} instead) — no real column can \ + ever be reported under this TYPE_NAME", + row.type_name, row.data_type + ); + } + } + + #[test] + fn type_info_rows_have_unique_data_types_per_name() { + let mut seen = std::collections::HashSet::new(); + for row in SQLITE_TYPE_INFO { + assert!( + seen.insert(row.type_name), + "duplicate type_name in SQLITE_TYPE_INFO: {}", + row.type_name + ); + } + } + + #[test] + fn type_info_column_size_matches_default_precision() { + // Consistency requirement: a row's COLUMN_SIZE must agree with what + // SQLColumns/SQLDescribeCol report for an undeclared column of that + // type (`default_precision_for_type`), or a driver could advertise + // one max length in SQLGetTypeInfo and a different one everywhere + // else (the same category of defect as a type missing from the type + // list, just for size instead of presence). + // + // This assertion is not masking a real possible divergence: both + // sides of the TIME/TIMESTAMP comparison below + // read the exact same `MAX_FRACTIONAL_SECONDS_PRECISION` constant, + // by design (see that constant's doc comment) -- SQLite has no + // schema-declarable temporal scale for a column to differ by, so + // "the data source's maximum" and "an undeclared column's default" + // are the same number *by construction*, not by coincidence. This + // test therefore only guards against the two call sites drifting to + // read *different* constants; it cannot catch the constant itself + // being wrong for SQLite's actual format, which is why + // `sqlite_temporal_default_precision_matches_documented_iso8601_format` + // below pins the concrete expected numbers instead. + let declared = [ + "INTEGER", + "BIGINT", + "SMALLINT", + "TINYINT", + "BOOLEAN", + "REAL", + "BLOB", + "DECIMAL", + "TEXT", + "DATE", + "TIME", + "TIMESTAMP", + ]; + for decl in declared { + let sql_type = crate::type_conversion::sqlite_type_to_sql_data_type(decl); + let expected = + i32::try_from(crate::type_conversion::default_precision_for_type(sql_type)) + .unwrap_or(i32::MAX); + let row = SQLITE_TYPE_INFO + .iter() + .find(|row| row.data_type == sql_type) + .unwrap_or_else(|| panic!("no SQLGetTypeInfo row for {sql_type:?} ({decl:?})")); + assert_eq!( + row.column_size, expected, + "column_size for {decl:?} ({sql_type:?}) row {:?} does not match \ + default_precision_for_type", + row.type_name + ); + } + } + + #[test] + fn sqlite_temporal_default_precision_matches_documented_iso8601_format() { + // Pins the concrete numbers independently of + // `MAX_FRACTIONAL_SECONDS_PRECISION`'s value, so this fails if that + // constant is ever 0 (or anything else) rather than only proving two + // call sites agree with each other. 12 = 9 + 3 and + // 23 = 20 + 3 per the ODBC "Column Size" appendix's TIME/TIMESTAMP + // formulas, evaluated at SQLite's documented 3-fractional-digit + // ISO-8601 format (`YYYY-MM-DD HH:MM:SS.SSS`, format 4/7 at + // ) -- see + // `MAX_FRACTIONAL_SECONDS_PRECISION`'s doc comment in + // `type_conversion.rs`. + assert_eq!( + crate::type_conversion::default_precision_for_type(SqlDataType::TIME), + 12, + "TIME default precision should be 9 + 3 fractional digits (\"HH:MM:SS.SSS\")" + ); + assert_eq!( + crate::type_conversion::default_precision_for_type(SqlDataType::TIMESTAMP), + 23, + "TIMESTAMP default precision should be 20 + 3 fractional digits \ + (\"YYYY-MM-DD HH:MM:SS.SSS\")" + ); + } + + #[test] + fn type_info_rows_sorted_by_data_type_then_type_name() { + // Spec (SQLGetTypeInfo): "ordered by DATA_TYPE and then ... TYPE_NAME, + // both ascending." DATA_TYPE is a signed i16 (negative for ODBC + // extension types), so the comparison must not treat it as unsigned. + // This walks adjacent pairs rather than asserting a fixed sequence, + // so it keeps holding as rows are added or reordered. + for pair in SQLITE_TYPE_INFO.windows(2) { + let (prev, next) = (&pair[0], &pair[1]); + assert!( + prev.data_type.0 <= next.data_type.0, + "SQLITE_TYPE_INFO not sorted by DATA_TYPE: {:?} (DATA_TYPE={}) \ + appears before {:?} (DATA_TYPE={})", + prev.type_name, + prev.data_type.0, + next.type_name, + next.data_type.0 + ); + if prev.data_type == next.data_type { + assert!( + prev.type_name <= next.type_name, + "rows sharing DATA_TYPE={} not sorted by TYPE_NAME: {:?} appears \ + before {:?}", + prev.data_type.0, + prev.type_name, + next.type_name + ); + } + } + } + + /// Guards that SQL_DRIVER_VER is derived from the crate version rather + /// than a hand-transcribed string that must be remembered whenever + /// Cargo.toml changes. + /// + /// The macro's three components are cross-checked against the *full* + /// `CARGO_PKG_VERSION` string rather than against the same three + /// `CARGO_PKG_VERSION_*` variables the macro reads. Restating the macro's + /// own expansion would assert nothing -- it would pass even if the macro + /// wired PATCH where MINOR belongs, because both sides would carry the + /// same mistake. Going through the combined string catches exactly that. + #[test] + fn driver_version_tracks_the_crate_version() { + let (major, minor, release) = + stackable_odbc_core::types::parse_dotted_version(env!("CARGO_PKG_VERSION")) + .expect("Cargo always supplies a parseable package version"); + assert_eq!( + stackable_odbc_core::driver_version!(), + stackable_odbc_core::types::format_odbc_version(major, minor, release) + ); + } + + // --- SQLGetInfo capability bitmaps --- + + /// SQLite has every ODBC aggregate. + #[test] + fn aggregate_functions_covers_all_seven() { + assert_eq!( + SQLITE_AGGREGATE_FUNCTIONS, + SQL_AF_AVG + | SQL_AF_COUNT + | SQL_AF_MAX + | SQL_AF_MIN + | SQL_AF_SUM + | SQL_AF_DISTINCT + | SQL_AF_ALL + ); + } + + /// Quantified comparison is genuinely absent: `< ALL`, `< ANY` and + /// `< SOME` all fail to prepare. SQLite's ALL/ANY are set quantifiers on + /// compound selects, not comparison quantifiers. Claiming it would make a + /// BI tool fold a predicate SQLite rejects. + #[test] + fn sql92_predicates_excludes_quantified_comparison_and_match() { + assert_eq!( + SQLITE_SQL92_PREDICATES, + SQL_SP_EXISTS + | SQL_SP_ISNOTNULL + | SQL_SP_ISNULL + | SQL_SP_LIKE + | SQL_SP_IN + | SQL_SP_BETWEEN + | SQL_SP_COMPARISON + ); + for absent in [ + SQL_SP_QUANTIFIED_COMPARISON, + SQL_SP_MATCH_FULL, + SQL_SP_MATCH_PARTIAL, + SQL_SP_MATCH_UNIQUE_FULL, + SQL_SP_MATCH_UNIQUE_PARTIAL, + SQL_SP_OVERLAPS, + SQL_SP_UNIQUE, + ] { + assert_eq!(SQLITE_SQL92_PREDICATES & absent, 0); + } + } + + /// RIGHT and FULL OUTER JOIN arrived in SQLite 3.39.0 and this build is + /// 3.53.2, so both are claimed -- verified by live probe, not assumed. + #[test] + fn sql92_join_operators_includes_right_and_full_outer() { + assert_eq!( + SQLITE_SQL92_JOIN_OPERATORS, + SQL_SRJO_CROSS_JOIN + | SQL_SRJO_EXCEPT_JOIN + | SQL_SRJO_FULL_OUTER_JOIN + | SQL_SRJO_INNER_JOIN + | SQL_SRJO_INTERSECT_JOIN + | SQL_SRJO_LEFT_OUTER_JOIN + | SQL_SRJO_NATURAL_JOIN + | SQL_SRJO_RIGHT_OUTER_JOIN + ); + assert_eq!( + SQLITE_SQL92_JOIN_OPERATORS & SQL_SRJO_CORRESPONDING_CLAUSE, + 0 + ); + assert_eq!(SQLITE_SQL92_JOIN_OPERATORS & SQL_SRJO_UNION_JOIN, 0); + } + + #[test] + fn sql92_value_expressions_covers_all_four() { + assert_eq!( + SQLITE_SQL92_VALUE_EXPRESSIONS, + SQL_SVE_CASE | SQL_SVE_CAST | SQL_SVE_COALESCE | SQL_SVE_NULLIF + ); + } + + /// The bundled build compiles out SQLITE_ENABLE_MATH_FUNCTIONS, so the + /// whole trig/log/power set is gone. `sign()` survives because it is a + /// *core* function, not a math one -- the one flag that would be wrong if + /// inferred from "math functions are off". + #[test] + fn numeric_functions_is_only_the_core_three() { + assert_eq!( + SQLITE_NUMERIC_FUNCTIONS, + SQL_FN_NUM_ABS | SQL_FN_NUM_SIGN | SQL_FN_NUM_ROUND + ); + for absent in [ + SQL_FN_NUM_COS, + SQL_FN_NUM_SQRT, + SQL_FN_NUM_POWER, + SQL_FN_NUM_LOG, + SQL_FN_NUM_CEILING, + SQL_FN_NUM_FLOOR, + SQL_FN_NUM_TRUNCATE, + SQL_FN_NUM_MOD, + SQL_FN_NUM_RAND, + ] { + assert_eq!(SQLITE_NUMERIC_FUNCTIONS & absent, 0); + } + } + + /// LOCATE is excluded on purpose: `instr(haystack, needle)` reverses + /// ODBC's `LOCATE(needle, haystack)`, so claiming it yields silently + /// wrong answers rather than a clean failure. + #[test] + fn string_functions_excludes_reversed_locate() { + assert_eq!( + SQLITE_STRING_FUNCTIONS, + SQL_FN_STR_CONCAT + | SQL_FN_STR_LTRIM + | SQL_FN_STR_LENGTH + | SQL_FN_STR_LCASE + | SQL_FN_STR_REPLACE + | SQL_FN_STR_RTRIM + | SQL_FN_STR_SUBSTRING + | SQL_FN_STR_UCASE + | SQL_FN_STR_ASCII + | SQL_FN_STR_CHAR + | SQL_FN_STR_SOUNDEX + | SQL_FN_STR_OCTET_LENGTH + ); + for absent in [ + SQL_FN_STR_LOCATE, + SQL_FN_STR_LOCATE_2, + SQL_FN_STR_LEFT, + SQL_FN_STR_RIGHT, + SQL_FN_STR_REPEAT, + SQL_FN_STR_SPACE, + SQL_FN_STR_INSERT, + SQL_FN_STR_DIFFERENCE, + SQL_FN_STR_CHAR_LENGTH, + SQL_FN_STR_CHARACTER_LENGTH, + SQL_FN_STR_BIT_LENGTH, + SQL_FN_STR_POSITION, + ] { + assert_eq!(SQLITE_STRING_FUNCTIONS & absent, 0); + } + } + + /// SQLite has no user and no scalar database-name function. + #[test] + fn system_functions_is_only_ifnull() { + assert_eq!(SQLITE_SYSTEM_FUNCTIONS, SQL_FN_SYS_IFNULL); + } + + /// SQLite has no year()/month()/day() -- only strftime() with a format + /// string, which is not an equivalent function. Only the current-date and + /// current-time family is claimed. + #[test] + fn timedate_functions_is_only_the_current_datetime_family() { + assert_eq!( + SQLITE_TIMEDATE_FUNCTIONS, + SQL_FN_TD_NOW + | SQL_FN_TD_CURDATE + | SQL_FN_TD_CURTIME + | SQL_FN_TD_CURRENT_DATE + | SQL_FN_TD_CURRENT_TIME + | SQL_FN_TD_CURRENT_TIMESTAMP + ); + for absent in [ + SQL_FN_TD_YEAR, + SQL_FN_TD_MONTH, + SQL_FN_TD_DAYOFMONTH, + SQL_FN_TD_QUARTER, + SQL_FN_TD_EXTRACT, + SQL_FN_TD_TIMESTAMPADD, + SQL_FN_TD_TIMESTAMPDIFF, + SQL_FN_TD_DAYNAME, + SQL_FN_TD_MONTHNAME, + ] { + assert_eq!(SQLITE_TIMEDATE_FUNCTIONS & absent, 0); + } + } + + #[test] + fn get_functions_advertises_data_at_execution() { + let f = get_functions(); + assert!(f.contains(&FunctionId::ParamData), "SQLParamData missing"); + assert!(f.contains(&FunctionId::PutData), "SQLPutData missing"); + } + + #[test] + fn get_functions_has_no_duplicates() { + let f = get_functions(); + let ids: Vec = f.iter().map(|id| *id as u16).collect(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + ids.len(), + "duplicate FunctionId in get_functions" + ); + } +} diff --git a/src/backend/metadata.rs b/src/backend/metadata.rs new file mode 100644 index 0000000..6509e9d --- /dev/null +++ b/src/backend/metadata.rs @@ -0,0 +1,1717 @@ +//! Catalog metadata for the SQLite backend (`tables`, `columns`, +//! `primary_keys`, `foreign_keys`, `statistics`, `special_columns`), derived +//! from SQLite's `PRAGMA` introspection and `sqlite_master`, plus the private +//! query helpers those functions share. + +use stackable_odbc_core::backend::Backend; +use stackable_odbc_core::errors::OdbcError; +use stackable_odbc_core::types::{ + ColumnDescriptor, ColumnValue, ColumnsResultCol, ForeignKeysResultCol, IdentifierType, + Nullable, PrimaryKeysResultCol, SQL_CASCADE, SQL_INDEX_OTHER, SQL_NO_ACTION, SQL_PC_NOT_PSEUDO, + SQL_PC_PSEUDO, SQL_RESTRICT, SQL_SET_DEFAULT, SQL_SET_NULL, SQL_TABLE_STAT, Scope, SqlDataType, + TablesResultCol, special_columns_columns, statistics_columns, +}; + +/// Column indices for `PRAGMA table_info(table)`. +/// +/// Columns: cid (0), name (1), type (2), notnull (3), dflt_value (4), pk (5) +mod pragma_table_info_col { + pub const CID: usize = 0; + pub const NAME: usize = 1; + pub const TYPE: usize = 2; + pub const NOT_NULL: usize = 3; + pub const DFT_VALUE: usize = 4; + pub const PK: usize = 5; +} + +/// Column ordinals of `PRAGMA index_list()`. +mod pragma_index_list_col { + pub const NAME: usize = 1; + pub const UNIQUE: usize = 2; + pub const PARTIAL: usize = 4; +} + +/// Column ordinals of `PRAGMA index_xinfo()`. +mod pragma_index_xinfo_col { + pub const NAME: usize = 2; + pub const DESC: usize = 3; + pub const KEY: usize = 5; +} + +/// Column indices for `PRAGMA foreign_key_list(table)`. +/// +/// Columns: id (0), seq (1), table (2), from (3), to (4), on_update (5), on_delete (6), match (7) +mod pragma_fk_col { + pub const SEQ: usize = 1; + pub const TABLE: usize = 2; + pub const FROM: usize = 3; + pub const TO: usize = 4; + pub const ON_UPDATE: usize = 5; + pub const ON_DELETE: usize = 6; +} + +use super::SqliteError; +use super::info::sqlite_bare_type_name; +use super::{SqliteBackend, SqliteConnection, SqliteStatement, map_sqlite_error}; +use crate::type_conversion::{ + default_precision_for_type, sqlite_declared_type_precision, sqlite_declared_type_scale, + sqlite_type_to_sql_data_type, +}; + +/// Bytes per character used for `CHAR_OCTET_LENGTH`. UTF-16 encodes a character +/// in at most 4 bytes (a surrogate pair). +const BYTES_PER_CHAR: i32 = 4; + +/// Convert a SQLite foreign key action string to its ODBC numeric constant. +fn fk_action_to_odbc(action: &str) -> i16 { + match action.to_ascii_uppercase().as_str() { + "CASCADE" => SQL_CASCADE, + "RESTRICT" => SQL_RESTRICT, + "SET NULL" => SQL_SET_NULL, + "SET DEFAULT" => SQL_SET_DEFAULT, + _ => SQL_NO_ACTION, // "NO ACTION" and anything else + } +} + +/// Column descriptors for the `SQLTables` result set. +/// +/// Shared with every other driver via [`TablesResultCol::all_descriptors`]. +/// Widths come from `catalog_result_column_widths()` so they stay consistent +/// with this driver's `SQL_MAX_TABLE_NAME_LEN` of 128. +fn tables_columns() -> Vec { + TablesResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()) +} + +/// Query `sqlite_master` for table **and view** names, filtered by an optional +/// LIKE pattern. Used by `SQLColumns`, whose `TableName` argument is a search +/// pattern and whose result set is defined over tables and views alike. +/// +/// Contrast with [`tables_to_inspect`], which matches an exact name and +/// excludes views; the two are deliberately not interchangeable. +fn collect_matching_tables( + db: &rusqlite::Connection, + table_filter: Option<&str>, +) -> Result, rusqlite::Error> { + let table_sql = if table_filter.is_some() { + "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name LIKE ?1 ESCAPE '\\' ORDER BY name" + } else { + "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY name" + }; + let mut table_stmt = db.prepare(table_sql)?; + let mut table_rows = if let Some(t) = table_filter { + table_stmt.query(rusqlite::params![t])? + } else { + table_stmt.query([])? + }; + let mut table_names = Vec::new(); + while let Some(row) = table_rows.next()? { + table_names.push(row.get(0)?); + } + Ok(table_names) +} + +/// Convert one row from `PRAGMA table_info(table_name)` into an ODBC SQLColumns row. +/// `table_name` is the table the column belongs to. +/// `col_name` is the column name, `col_type` is the declared type, `not_null` is true if NOT NULL, +/// `ordinal` is the 0-based column index from PRAGMA (cid), `dflt_value` is the default value. +fn build_column_row( + table_name: &str, + col_name: &str, + col_type: &str, + not_null: bool, + ordinal: i64, + dflt_value: Option<&str>, +) -> Vec { + let sql_type = sqlite_type_to_sql_data_type(col_type); + let precision = sqlite_declared_type_precision(col_type); + let scale = sqlite_declared_type_scale(col_type); + let nullable = if not_null { + Nullable::SqlNoNulls + } else { + Nullable::SqlNullable + }; + let is_numeric = matches!( + sql_type, + SqlDataType::SMALLINT + | SqlDataType::DOUBLE + | SqlDataType::EXT_BIG_INT + | SqlDataType::EXT_TINY_INT + | SqlDataType::DECIMAL + ); + // sqlite_type_to_sql_data_type() only ever returns SqlDataType::EXT_W_VARCHAR for + // character types (both the explicit CHAR/VARCHAR/TEXT/CLOB/... match arm and the + // sqlite_affinity() fallback map to EXT_W_VARCHAR; there is no case that returns + // the plain SqlDataType::VARCHAR for SQLite-declared columns). + let is_char = sql_type == SqlDataType::EXT_W_VARCHAR; + // Likewise, BLOB (both the explicit match arm and the affinity fallback) + // is the only SQLite-declared type mapped to SqlDataType::EXT_VAR_BINARY. + let is_binary = sql_type == SqlDataType::EXT_VAR_BINARY; + + let column_size = i32::try_from(precision).unwrap_or_else(|_| { + tracing::warn!(precision, "declared column size exceeds i32"); + i32::MAX + }); + + // CHAR_OCTET_LENGTH (ODBC 3.0 SQLColumns column 16): the maximum length in + // bytes of a character or binary column; NULL for all other data types. + // + // Character columns declare their length in UTF-16 characters (up to + // BYTES_PER_CHAR bytes per character). SQLite lets a declared length reach + // e.g. VARCHAR(2000000000), so the product must be checked rather than + // wrapping (release builds) or panicking (debug builds); report NULL when + // it does not fit i32. + // + // Binary columns (BLOB) declare their length in bytes already, so it is + // passed through unmultiplied, but the u32 -> i32 conversion still needs + // to be checked for the same overflow reason. + let char_octet_length = if is_char { + i32::try_from(precision) + .ok() + .and_then(|p| p.checked_mul(BYTES_PER_CHAR)) + .map(ColumnValue::I32) + .unwrap_or(ColumnValue::Null) + } else if is_binary { + i32::try_from(precision) + .ok() + .map(ColumnValue::I32) + .unwrap_or(ColumnValue::Null) + } else { + ColumnValue::Null + }; + + let ordinal_position = i32::try_from(ordinal + 1).unwrap_or_else(|_| { + tracing::warn!(ordinal, "ordinal position exceeds i32"); + i32::MAX + }); + + vec![ + ColumnValue::Null, // TABLE_CAT + ColumnValue::Null, // TABLE_SCHEM + ColumnValue::String(table_name.to_string()), // TABLE_NAME + ColumnValue::String(col_name.to_string()), // COLUMN_NAME + ColumnValue::I16(sql_type.0), // DATA_TYPE + // Spec (SQLColumns.TYPE_NAME / SQL_DESC_TYPE_NAME): both list bare + // examples ("CHAR", "VARCHAR", ...), not declarations, so `col_type` + // ("VARCHAR(50)") matches no `SQLGetTypeInfo` row. + // `sqlite_bare_type_name` (also used by `SQL_DESC_TYPE_NAME` in + // execute.rs) returns the bare name that does; the declared length + // is still carried above via COLUMN_SIZE (`precision`), just not the + // name. + ColumnValue::String(sqlite_bare_type_name(sql_type).to_string()), // TYPE_NAME + ColumnValue::I32(column_size), // COLUMN_SIZE + ColumnValue::I32(0), // BUFFER_LENGTH + ColumnValue::I16(scale), // DECIMAL_DIGITS + if is_numeric { + ColumnValue::I16(10) + } else { + ColumnValue::Null + }, // NUM_PREC_RADIX + ColumnValue::I16(nullable.into()), // NULLABLE + ColumnValue::Null, // REMARKS + match dflt_value { + Some(v) => ColumnValue::String(v.to_string()), + None => ColumnValue::Null, + }, // COLUMN_DEF + ColumnValue::I16(sql_type.0), // SQL_DATA_TYPE + ColumnValue::Null, // SQL_DATETIME_SUB + char_octet_length, // CHAR_OCTET_LENGTH + ColumnValue::I32(ordinal_position), // ORDINAL_POSITION + ColumnValue::String(nullable.as_is_nullable_str().to_string()), // IS_NULLABLE + ] +} + +/// Return the base tables to inspect: the exact named table if one is given, +/// otherwise every `type='table'` entry in `sqlite_master`. Used by +/// `SQLPrimaryKeys`/`SQLForeignKeys`, which take an exact table name (not a +/// pattern) and for which views are out of scope. +/// +/// Contrast with [`collect_matching_tables`], which treats its argument as a +/// LIKE pattern and includes views; the two are deliberately not +/// interchangeable. +fn tables_to_inspect( + db: &rusqlite::Connection, + table_name: Option<&str>, +) -> Result, rusqlite::Error> { + if let Some(t) = table_name { + return Ok(vec![t.to_string()]); + } + let mut stmt = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?; + let mut rows = stmt.query([])?; + let mut names = Vec::new(); + while let Some(row) = rows.next()? { + names.push(row.get(0)?); + } + Ok(names) +} + +pub(super) fn tables( + conn: &SqliteConnection, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + table_type: Option<&str>, +) -> Result { + // ODBC spec: empty string is a valid (but useless for SQLite) filter; treat as no-filter. + let catalog = catalog.filter(|s| !s.is_empty()); + let schema = schema.filter(|s| !s.is_empty()); + // The SQLTables TableType="%" discovery case requires an EMPTY TableName; + // evaluate that before the "%"-stripping normalization below, so a literal + // TableName="%" (meaning "all tables") does not masquerade as discovery. + let table_name_is_empty = table.is_none_or(|s| s.is_empty()); + // Treat "%" (match-all wildcard) as no-filter too, to avoid LIKE '%' overhead. + let table = table.filter(|s| !s.is_empty() && *s != "%"); + + // ODBC spec §SQLTables: TableType="%" with empty catalog/schema/table returns + // the list of valid table types for the data source. SQLite exposes tables + // and views. + if table_type == Some("%") && catalog.is_none() && schema.is_none() && table_name_is_empty { + let type_rows = ["TABLE", "VIEW"] + .into_iter() + .map(|t| { + vec![ + ColumnValue::Null, // TABLE_CAT + ColumnValue::Null, // TABLE_SCHEM + ColumnValue::Null, // TABLE_NAME + ColumnValue::String(t.to_string()), // TABLE_TYPE + ColumnValue::Null, // REMARKS + ] + }) + .collect(); + return Ok(SqliteStatement::new(tables_columns(), type_rows)); + } + + let table_type = table_type.filter(|s| !s.is_empty() && *s != "%"); + + // ODBC spec §8.3: special single-argument discovery calls. + // catalog="%" with empty schema/table → return list of valid catalogs. + // SQLite has no catalogs (TABLE_CAT is always NULL), so return empty result. + if catalog == Some("%") && schema.is_none() && table.is_none() { + return Ok(SqliteStatement::new(tables_columns(), vec![])); + } + // schema="%" with empty catalog/table → return list of valid schemas. + // SQLite has no schemas, so return empty result. + if schema == Some("%") && catalog.is_none() && table.is_none() { + return Ok(SqliteStatement::new(tables_columns(), vec![])); + } + + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), + })?; + + // TableName is a search pattern (ODBC §8.3); use LIKE so "%" works as wildcard. + let sql = if table.is_some() { + "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name LIKE ?1 ESCAPE '\\' ORDER BY type, name" + } else { + "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY type, name" + }; + + let mut stmt = db.prepare(sql).map_err(map_sqlite_error)?; + let mut rows = Vec::new(); + let mut raw_rows = if let Some(t) = table { + stmt.query(rusqlite::params![t]).map_err(map_sqlite_error)? + } else { + stmt.query([]).map_err(map_sqlite_error)? + }; + while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { + let name: String = row.get(0).map_err(map_sqlite_error)?; + let type_str: String = row.get(1).map_err(map_sqlite_error)?; + let odbc_type = if type_str == "view" { "VIEW" } else { "TABLE" }; + + // Filter by table_type if specified (comma-separated list, not a pattern). + if let Some(tt) = table_type { + let allowed: Vec<&str> = tt.split(',').map(|s| s.trim().trim_matches('\'')).collect(); + if !allowed.iter().any(|a| a.eq_ignore_ascii_case(odbc_type)) { + continue; + } + } + + rows.push(vec![ + ColumnValue::Null, // TABLE_CAT + ColumnValue::Null, // TABLE_SCHEM + ColumnValue::String(name), // TABLE_NAME + ColumnValue::String(odbc_type.to_string()), // TABLE_TYPE + ColumnValue::Null, // REMARKS + ]); + } + + Ok(SqliteStatement::new(tables_columns(), rows)) +} + +pub(super) fn columns( + conn: &SqliteConnection, + _catalog: Option<&str>, + _schema: Option<&str>, + table: Option<&str>, + column: Option<&str>, +) -> Result { + // Same normalization as tables(): empty string and "%" both mean "no filter". + let table = table.filter(|s| !s.is_empty() && *s != "%"); + let column = column.filter(|s| !s.is_empty() && *s != "%"); + + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), + })?; + + let table_names = collect_matching_tables(&db, table).map_err(map_sqlite_error)?; + + let mut rows = Vec::new(); + for table_name in &table_names { + // The pragma_table_info table-valued function binds its argument (the + // `PRAGMA table_info(...)` statement form does not); use it so the name + // needs no manual escaping. Same columns, same order as the PRAGMA. + // + // ColumnName is an ODBC search pattern (spec §SQLColumns): filter with + // LIKE ... ESCAPE '\' in SQL so `%`/`_`/`\` follow ODBC semantics. + let pragma_sql = if column.is_some() { + "SELECT * FROM pragma_table_info(?1) WHERE name LIKE ?2 ESCAPE '\\'" + } else { + "SELECT * FROM pragma_table_info(?1)" + }; + let mut pragma_stmt = db.prepare(pragma_sql).map_err(map_sqlite_error)?; + let mut pragma_rows = if let Some(c) = column { + pragma_stmt + .query(rusqlite::params![table_name, c]) + .map_err(map_sqlite_error)? + } else { + pragma_stmt + .query(rusqlite::params![table_name]) + .map_err(map_sqlite_error)? + }; + + while let Some(row) = pragma_rows.next().map_err(map_sqlite_error)? { + let cid: i64 = row + .get(pragma_table_info_col::CID) + .map_err(map_sqlite_error)?; + let col_name: String = row + .get(pragma_table_info_col::NAME) + .map_err(map_sqlite_error)?; + let decl_type: String = row + .get::<_, Option>(pragma_table_info_col::TYPE) + .map_err(map_sqlite_error)? + .unwrap_or_else(|| "TEXT".to_string()); + let notnull: i64 = row + .get(pragma_table_info_col::NOT_NULL) + .map_err(map_sqlite_error)?; + let dflt_value: Option = row + .get(pragma_table_info_col::DFT_VALUE) + .map_err(map_sqlite_error)?; + + rows.push(build_column_row( + table_name, + &col_name, + &decl_type, + notnull != 0, + cid, + dflt_value.as_deref(), + )); + } + } + + let columns = ColumnsResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()); + + Ok(SqliteStatement::new(columns, rows)) +} + +/// Return primary key columns for the given table. +/// +/// Uses `PRAGMA table_info(table)` and filters rows where `pk > 0`. +/// The `pk` column is the 1-based key sequence number. +/// +/// Spec: +pub(super) fn primary_keys( + conn: &SqliteConnection, + _catalog: Option<&str>, + _schema: Option<&str>, + table: Option<&str>, +) -> Result { + let db = conn.conn.lock().map_err(|e| { + OdbcError::general( + format!("Mutex poisoned: {e}"), + stackable_odbc_core::types::SqlState::general_error(), + ) + })?; + + // Collect table names to query (either the specific one or all tables). + let table_names = + tables_to_inspect(&db, table).map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + let mut result_rows: Vec> = Vec::new(); + for table_name in &table_names { + // The pragma_table_info table-valued function binds its argument (the + // `PRAGMA table_info(...)` statement form does not); use it so the name + // needs no manual escaping. Same columns, same order as the PRAGMA. + let mut pragma_stmt = db + .prepare("SELECT * FROM pragma_table_info(?1)") + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let mut pragma_rows = pragma_stmt + .query(rusqlite::params![table_name]) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + // Collect pk columns: (key_seq, col_name) + let mut pk_cols: Vec<(i64, String)> = Vec::new(); + while let Some(row) = pragma_rows + .next() + .map_err(|e| OdbcError::from(map_sqlite_error(e)))? + { + let pk_seq: i64 = row + .get(pragma_table_info_col::PK) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + if pk_seq > 0 { + let col_name: String = row + .get(pragma_table_info_col::NAME) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + pk_cols.push((pk_seq, col_name)); + } + } + + // Sort by KEY_SEQ (the pk column from PRAGMA is already 1-based). + pk_cols.sort_by_key(|(seq, _)| *seq); + + for (key_seq, col_name) in pk_cols { + result_rows.push(vec![ + ColumnValue::Null, // TABLE_CAT + ColumnValue::Null, // TABLE_SCHEM + ColumnValue::String(table_name.clone()), // TABLE_NAME + ColumnValue::String(col_name), // COLUMN_NAME + ColumnValue::I16(i16::try_from(key_seq).unwrap_or_else(|_| { + tracing::warn!(key_seq, "key sequence exceeds i16"); + i16::MAX + })), // KEY_SEQ + ColumnValue::Null, // PK_NAME (not available in SQLite) + ]); + } + } + + let columns = + PrimaryKeysResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()); + + Ok(SqliteStatement::new(columns, result_rows)) +} + +/// Return foreign key relationships involving the given tables. +/// +/// If `fk_table` is given, uses `PRAGMA foreign_key_list(fk_table)` to enumerate the FKs +/// defined on that table. If only `pk_table` is given, all tables are scanned. +/// +/// Spec: +pub(super) fn foreign_keys( + conn: &SqliteConnection, + _pk_catalog: Option<&str>, + _pk_schema: Option<&str>, + pk_table: Option<&str>, + _fk_catalog: Option<&str>, + _fk_schema: Option<&str>, + fk_table: Option<&str>, +) -> Result { + let db = conn.conn.lock().map_err(|e| { + OdbcError::general( + format!("Mutex poisoned: {e}"), + stackable_odbc_core::types::SqlState::general_error(), + ) + })?; + + // Which FK tables do we query? + let fk_table_names = + tables_to_inspect(&db, fk_table).map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + let mut result_rows: Vec> = Vec::new(); + + for fk_tbl in &fk_table_names { + let pragma_sql = format!("PRAGMA foreign_key_list('{}')", fk_tbl.replace('\'', "''")); + let mut pragma_stmt = db + .prepare(&pragma_sql) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let mut pragma_rows = pragma_stmt + .query([]) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + while let Some(row) = pragma_rows + .next() + .map_err(|e| OdbcError::from(map_sqlite_error(e)))? + { + let seq: i64 = row + .get(pragma_fk_col::SEQ) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let referenced_table: String = row + .get(pragma_fk_col::TABLE) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let from_col: String = row + .get(pragma_fk_col::FROM) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let to_col: Option = row + .get(pragma_fk_col::TO) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let on_update: String = row + .get(pragma_fk_col::ON_UPDATE) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let on_delete: String = row + .get(pragma_fk_col::ON_DELETE) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + // Filter by pk_table if specified. + if let Some(pkt) = pk_table + && !referenced_table.eq_ignore_ascii_case(pkt) + { + continue; + } + + let pk_col = match to_col { + Some(c) => ColumnValue::String(c), + // SQLite allows FK without explicit column; treat as NULL. + None => ColumnValue::Null, + }; + + result_rows.push(vec![ + ColumnValue::Null, // PKTABLE_CAT + ColumnValue::Null, // PKTABLE_SCHEM + ColumnValue::String(referenced_table), // PKTABLE_NAME + pk_col, // PKCOLUMN_NAME + ColumnValue::Null, // FKTABLE_CAT + ColumnValue::Null, // FKTABLE_SCHEM + ColumnValue::String(fk_tbl.clone()), // FKTABLE_NAME + ColumnValue::String(from_col), // FKCOLUMN_NAME + ColumnValue::I16(i16::try_from(seq + 1).unwrap_or_else(|_| { + tracing::warn!(seq, "key sequence exceeds i16"); + i16::MAX + })), // KEY_SEQ (1-based) + ColumnValue::I16(fk_action_to_odbc(&on_update)), // UPDATE_RULE + ColumnValue::I16(fk_action_to_odbc(&on_delete)), // DELETE_RULE + ColumnValue::Null, // FK_NAME (not in SQLite PRAGMA) + ColumnValue::Null, // PK_NAME (not in SQLite PRAGMA) + ColumnValue::Null, // DEFERRABILITY + ]); + } + } + + let columns = + ForeignKeysResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()); + + Ok(SqliteStatement::new(columns, result_rows)) +} + +/// Return index statistics for a single table (SQLStatistics). +/// +/// Emits a leading `SQL_TABLE_STAT` row (CARDINALITY from `sqlite_stat1` when +/// `ANALYZE` has populated it, else NULL; PAGES always NULL, honoring +/// SQL_QUICK), then one row per key column of each index from +/// `PRAGMA index_list` / `PRAGMA index_xinfo`. Rows are ordered per spec by +/// NON_UNIQUE, TYPE, INDEX_QUALIFIER (always NULL here), INDEX_NAME, +/// ORDINAL_POSITION, with the NULL NON_UNIQUE table-stat row first. +/// +/// Spec: +pub(super) fn statistics( + conn: &SqliteConnection, + _catalog: Option<&str>, + _schema: Option<&str>, + table: Option<&str>, + unique_only: bool, +) -> Result { + use stackable_odbc_core::types::{SQL_FALSE, SQL_TRUE}; + + let widths = SqliteBackend::catalog_result_column_widths(); + let columns = statistics_columns(&widths); + + // SQLStatistics.TableName cannot be a search pattern; an absent name has no + // table to describe, so return an empty (but correctly-shaped) result set. + let Some(table) = table.filter(|s| !s.is_empty()) else { + return Ok(SqliteStatement::new(columns, Vec::new())); + }; + + let db = conn.conn.lock().map_err(|e| { + OdbcError::general( + format!("Mutex poisoned: {e}"), + stackable_odbc_core::types::SqlState::general_error(), + ) + })?; + + // CARDINALITY for the table-stat row: read sqlite_stat1 only if present. + let cardinality = table_cardinality_from_stat1(&db, table); + + // Table-stat row (leading). TABLE_NAME (3) and TYPE (7) are the NOT NULL + // columns; everything index-specific is NULL. + let mut rows: Vec> = vec![vec![ + ColumnValue::Null, // TABLE_CAT + ColumnValue::Null, // TABLE_SCHEM + ColumnValue::String(table.to_string()), // TABLE_NAME + ColumnValue::Null, // NON_UNIQUE + ColumnValue::Null, // INDEX_QUALIFIER + ColumnValue::Null, // INDEX_NAME + ColumnValue::I16(SQL_TABLE_STAT), // TYPE + ColumnValue::Null, // ORDINAL_POSITION + ColumnValue::Null, // COLUMN_NAME + ColumnValue::Null, // ASC_OR_DESC + cardinality, // CARDINALITY + ColumnValue::Null, // PAGES + ColumnValue::Null, // FILTER_CONDITION + ]]; + + // Enumerate indexes. Use the pragma_ TVF form so the name binds safely. + let mut list_stmt = db + .prepare("SELECT * FROM pragma_index_list(?1)") + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let mut list_rows = list_stmt + .query(rusqlite::params![table]) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + // (index_name, is_unique, is_partial) + let mut indexes: Vec<(String, bool, bool)> = Vec::new(); + while let Some(r) = list_rows + .next() + .map_err(|e| OdbcError::from(map_sqlite_error(e)))? + { + let name: String = r + .get(pragma_index_list_col::NAME) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let unique: i64 = r + .get(pragma_index_list_col::UNIQUE) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let partial: i64 = r + .get(pragma_index_list_col::PARTIAL) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let is_unique = unique != 0; + if unique_only && !is_unique { + continue; + } + indexes.push((name, is_unique, partial != 0)); + } + drop(list_rows); + drop(list_stmt); + + for (index_name, is_unique, is_partial) in &indexes { + let mut xinfo_stmt = db + .prepare("SELECT * FROM pragma_index_xinfo(?1)") + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let mut xinfo_rows = xinfo_stmt + .query(rusqlite::params![index_name]) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + let mut ordinal: i16 = 0; + while let Some(r) = xinfo_rows + .next() + .map_err(|e| OdbcError::from(map_sqlite_error(e)))? + { + let key: i64 = r + .get(pragma_index_xinfo_col::KEY) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + if key == 0 { + continue; // auxiliary column (e.g. trailing rowid), not part of the key + } + ordinal += 1; + // COLUMN_NAME is NULL for an expression index; spec wants "" then. + let col_name: Option = r + .get(pragma_index_xinfo_col::NAME) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let desc: i64 = r + .get(pragma_index_xinfo_col::DESC) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + rows.push(vec![ + ColumnValue::Null, // TABLE_CAT + ColumnValue::Null, // TABLE_SCHEM + ColumnValue::String(table.to_string()), // TABLE_NAME + ColumnValue::I16(if *is_unique { + SQL_FALSE as i16 + } else { + SQL_TRUE as i16 + }), // NON_UNIQUE + ColumnValue::Null, // INDEX_QUALIFIER + ColumnValue::String(index_name.clone()), // INDEX_NAME + ColumnValue::I16(SQL_INDEX_OTHER), // TYPE + ColumnValue::I16(ordinal), // ORDINAL_POSITION + ColumnValue::String(col_name.unwrap_or_default()), // COLUMN_NAME ("" for expression) + ColumnValue::String(if desc != 0 { "D" } else { "A" }.into()), // ASC_OR_DESC + ColumnValue::Null, // CARDINALITY + ColumnValue::Null, // PAGES + if *is_partial { + ColumnValue::String(String::new()) + } else { + ColumnValue::Null + }, // FILTER_CONDITION + ]); + } + } + + // Order: table-stat row first (NON_UNIQUE NULL), then by NON_UNIQUE, TYPE, + // INDEX_NAME, ORDINAL_POSITION. Sort key extracts those columns. + rows.sort_by_key(|r| statistics_sort_key(r)); + + Ok(SqliteStatement::new(columns, rows)) +} + +/// Sort key implementing the SQLStatistics ordering. NULL NON_UNIQUE sorts +/// first (the table-stat row); NON_UNIQUE ascending (unique before non-unique); +/// then TYPE, INDEX_NAME, ORDINAL_POSITION. +fn statistics_sort_key(row: &[ColumnValue]) -> (i16, i16, String, i16) { + let non_unique = match &row[3] { + ColumnValue::I16(v) => *v, + _ => -1, // NULL -> before 0 (unique) and 1 (non-unique) + }; + let ty = match &row[6] { + ColumnValue::I16(v) => *v, + _ => 0, + }; + let index_name = match &row[5] { + ColumnValue::String(s) => s.clone(), + _ => String::new(), + }; + let ordinal = match &row[7] { + ColumnValue::I16(v) => *v, + _ => 0, + }; + (non_unique, ty, index_name, ordinal) +} + +/// Read the table row count from `sqlite_stat1` if `ANALYZE` has populated it. +/// The `stat` column's first whitespace-delimited token is the table row count. +/// Returns `ColumnValue::Null` when the stat table or row is absent. +fn table_cardinality_from_stat1(db: &rusqlite::Connection, table: &str) -> ColumnValue { + let query = "SELECT stat FROM sqlite_stat1 WHERE tbl = ?1 AND idx IS NULL LIMIT 1"; + let stat: Result = db.query_row(query, rusqlite::params![table], |r| r.get(0)); + match stat { + Ok(s) => s + .split_whitespace() + .next() + .and_then(|tok| tok.parse::().ok()) + .map(ColumnValue::I32) + .unwrap_or(ColumnValue::Null), + Err(_) => ColumnValue::Null, // no sqlite_stat1 (no ANALYZE) or no row + } +} + +/// Return the SQL_BEST_ROWID / SQL_ROWVER special columns for a table +/// (SQLSpecialColumns). +/// +/// SQLite has no engine-updated columns, so `SQL_ROWVER` is always empty. +/// For `SQL_BEST_ROWID` the identifier is, in priority order: a declared +/// `INTEGER PRIMARY KEY` column (a nameable rowid alias); otherwise the +/// `rowid` pseudo-column of an ordinary rowid table; otherwise the PRIMARY KEY +/// columns of a WITHOUT ROWID table. A request whose minimum `Scope` exceeds +/// the identifier's guaranteed scope yields an empty result set. +/// +/// Spec: +pub(super) fn special_columns( + conn: &SqliteConnection, + identifier_type: IdentifierType, + _catalog: Option<&str>, + _schema: Option<&str>, + table: Option<&str>, + scope: Scope, + _nullable: Nullable, // our identifiers are all NOT NULL -> Nullable never filters +) -> Result { + let widths = SqliteBackend::catalog_result_column_widths(); + let columns = special_columns_columns(&widths); + let empty = || Ok(SqliteStatement::new(columns.clone(), Vec::new())); + + // ROWVER: SQLite has no auto-updated columns. + if matches!(identifier_type, IdentifierType::RowVer) { + return empty(); + } + let Some(table) = table.filter(|s| !s.is_empty()) else { + return empty(); + }; + + let db = conn.conn.lock().map_err(|e| { + OdbcError::general( + format!("Mutex poisoned: {e}"), + stackable_odbc_core::types::SqlState::general_error(), + ) + })?; + + // Gather (name, decl_type, pk_seq) for every column via the pragma TVF. + let mut info_stmt = db + .prepare("SELECT * FROM pragma_table_info(?1)") + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let mut info_rows = info_stmt + .query(rusqlite::params![table]) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + + struct Col { + name: String, + decl_type: String, + pk: i64, + } + let mut cols: Vec= Vec::new(); + while let Some(r) = info_rows + .next() + .map_err(|e| OdbcError::from(map_sqlite_error(e)))? + { + let name: String = r + .get(pragma_table_info_col::NAME) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let decl_type: Option = r + .get(pragma_table_info_col::TYPE) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let pk: i64 = r + .get(pragma_table_info_col::PK) + .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + cols.push(Col { + name, + decl_type: decl_type.unwrap_or_default(), + pk, + }); + } + drop(info_rows); + drop(info_stmt); + + if cols.is_empty() { + return empty(); // table does not exist / has no columns + } + + let pk_cols: Vec<&Col> = { + let mut v: Vec<&Col> = cols.iter().filter(|c| c.pk > 0).collect(); + v.sort_by_key(|c| c.pk); + v + }; + + // Case 1: single declared INTEGER PRIMARY KEY -> the nameable rowid alias. + let integer_pk = + if pk_cols.len() == 1 && pk_cols[0].decl_type.trim().eq_ignore_ascii_case("INTEGER") { + Some(pk_cols[0]) + } else { + None + }; + + // Decide identifier + guaranteed scope. + // guaranteed scope: TRANSACTION for the volatile rowid pseudo-column, + // SESSION for a declared key column. + let (rows, guaranteed): (Vec>, Scope) = if let Some(col) = integer_pk { + // A declared INTEGER PRIMARY KEY is an alias for the 8-byte 64-bit + // rowid, not a plain INTEGER column, so describe it with the same + // BIGINT/COLUMN_SIZE 19/BUFFER_LENGTH 8 shape as the rowid + // pseudo-column below, just under the real column name. + ( + vec![special_column_row_bigint( + &col.name, + SQL_PC_NOT_PSEUDO, + Scope::Session, + )], + Scope::Session, + ) + } else if table_is_rowid(&db, table)? { + // rowid pseudo-column (BIGINT). + ( + vec![special_column_row_bigint( + "rowid", + SQL_PC_PSEUDO, + Scope::Transaction, + )], + Scope::Transaction, + ) + } else { + // WITHOUT ROWID: the PRIMARY KEY columns. + if pk_cols.is_empty() { + return empty(); + } + ( + pk_cols + .iter() + .map(|c| { + special_column_row(&c.name, &c.decl_type, SQL_PC_NOT_PSEUDO, Scope::Session) + }) + .collect(), + Scope::Session, + ) + }; + + // Scope is a minimum; if we cannot meet it, return empty (spec). + if scope > guaranteed { + return empty(); + } + + Ok(SqliteStatement::new(columns, rows)) +} + +/// Build one SQLSpecialColumns row for a declared column, deriving its SQL type +/// from the same mapping SQLColumns uses. +fn special_column_row(name: &str, decl_type: &str, pseudo: i16, scope: Scope) -> Vec { + let sql_type = sqlite_type_to_sql_data_type(decl_type); + let column_size = i32::try_from(sqlite_declared_type_precision(decl_type)).unwrap_or(i32::MAX); + let scale = sqlite_declared_type_scale(decl_type); + vec![ + ColumnValue::I16(scope.into()), // SCOPE + ColumnValue::String(name.to_string()), // COLUMN_NAME + ColumnValue::I16(sql_type.0), // DATA_TYPE + ColumnValue::String(sqlite_bare_type_name(sql_type).to_string()), // TYPE_NAME + ColumnValue::I32(column_size), // COLUMN_SIZE + ColumnValue::I32(column_size), // BUFFER_LENGTH (approx: transfer octet length) + if scale > 0 { + ColumnValue::I16(scale) + } else { + ColumnValue::Null + }, // DECIMAL_DIGITS + ColumnValue::I16(pseudo), // PSEUDO_COLUMN + ] +} + +/// Build one SQLSpecialColumns row for the 64-bit rowid pseudo-column / an +/// INTEGER PRIMARY KEY reported as BIGINT. +fn special_column_row_bigint(name: &str, pseudo: i16, scope: Scope) -> Vec { + let sql_type = SqlDataType::EXT_BIG_INT; + let column_size = i32::try_from(default_precision_for_type(sql_type)).unwrap_or(i32::MAX); + vec![ + ColumnValue::I16(scope.into()), + ColumnValue::String(name.to_string()), + ColumnValue::I16(sql_type.0), + ColumnValue::String(sqlite_bare_type_name(sql_type).to_string()), + ColumnValue::I32(column_size), + ColumnValue::I32(8), // BUFFER_LENGTH: 8 bytes for a 64-bit integer + ColumnValue::Null, // DECIMAL_DIGITS: not applicable to integers + ColumnValue::I16(pseudo), + ] +} + +/// True if `table` is an ordinary rowid table. Probes `SELECT rowid`: a +/// WITHOUT ROWID table has no `rowid` column, so the prepare fails with +/// "no such column: rowid". +/// +/// In this rusqlite version (0.40.1) that prepare-time failure arrives as +/// `Error::SqlInputError { msg, .. }` (not `Error::SqliteFailure`); see +/// `map_sqlite_error`'s handling of the same shape in `backend.rs`. Only the +/// "no such column" message is treated as "not a rowid table"; any other +/// error (a genuine failure, not the WITHOUT ROWID case) is routed through +/// `map_sqlite_error`. +fn table_is_rowid(db: &rusqlite::Connection, table: &str) -> Result { + // Identifier cannot be bound; quote it, doubling embedded quotes. + let quoted = format!("\"{}\"", table.replace('"', "\"\"")); + match db.prepare(&format!("SELECT rowid FROM {quoted} LIMIT 0")) { + Ok(_) => Ok(true), + Err(rusqlite::Error::SqlInputError { ref msg, .. }) + if msg.starts_with("no such column") => + { + Ok(false) + } + // Any other error shape is a genuine failure. + Err(e) => Err(OdbcError::from(map_sqlite_error(e))), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use crate::backend::SqliteConnection; + use stackable_odbc_core::backend::StatementBackend; + use stackable_odbc_core::types::{CDataType, FetchResult, SQL_FALSE}; + + fn setup_test_db() -> SqliteConnection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE empty_table (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE types_test (id INTEGER, val REAL, label TEXT); + CREATE VIEW types_view AS SELECT id, label FROM types_test; + CREATE TABLE parent (pk INTEGER PRIMARY KEY, info TEXT); + CREATE TABLE child ( + id INTEGER PRIMARY KEY, + parent_pk INTEGER NOT NULL, + FOREIGN KEY (parent_pk) REFERENCES parent(pk) + );", + ) + .unwrap(); + SqliteConnection { + conn: Mutex::new(conn), + manual_commit: std::sync::atomic::AtomicBool::new(false), + } + } + + #[test] + fn tables_returns_all_tables_and_views() { + let conn = setup_test_db(); + let mut stmt = tables(&conn, None, None, None, None).unwrap(); + assert_eq!(stmt.column_count(), 5); + + let mut names = Vec::new(); + let mut types = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + if let ColumnValue::String(n) = + stmt.get_data(3, CDataType::Default).unwrap().into_owned() + { + names.push(n); + } + if let ColumnValue::String(t) = + stmt.get_data(4, CDataType::Default).unwrap().into_owned() + { + types.push(t); + } + } + assert!(names.contains(&"empty_table".to_string())); + assert!(names.contains(&"types_test".to_string())); + assert!(names.contains(&"types_view".to_string())); + assert!(names.contains(&"parent".to_string())); + assert!(names.contains(&"child".to_string())); + assert_eq!(types.iter().filter(|t| *t == "TABLE").count(), 4); + assert_eq!(types.iter().filter(|t| *t == "VIEW").count(), 1); + } + + #[test] + fn tables_filter_by_table_type() { + let conn = setup_test_db(); + let mut stmt = tables(&conn, None, None, None, Some("TABLE")).unwrap(); + let mut names = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + if let ColumnValue::String(n) = + stmt.get_data(3, CDataType::Default).unwrap().into_owned() + { + names.push(n); + } + } + assert!(names.contains(&"empty_table".to_string())); + assert!(names.contains(&"types_test".to_string())); + assert!(!names.contains(&"types_view".to_string())); + } + + #[test] + fn tables_filter_by_name() { + let conn = setup_test_db(); + let mut stmt = tables(&conn, None, None, Some("types_test"), None).unwrap(); + let mut count = 0; + while stmt.fetch().unwrap() == FetchResult::Row { + count += 1; + assert_eq!( + stmt.get_data(3, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("types_test".to_string()) + ); + } + assert_eq!(count, 1); + } + + #[test] + fn tables_table_type_percent_lists_table_types() { + let conn = setup_test_db(); + // SQL_ALL_TABLE_TYPES discovery: TableType="%", others empty. + let mut stmt = tables(&conn, Some(""), Some(""), Some(""), Some("%")).unwrap(); + let mut types = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + // TABLE_NAME (col 3) must be NULL for the discovery result set. + assert_eq!( + stmt.get_data(3, CDataType::Default).unwrap().into_owned(), + ColumnValue::Null + ); + if let ColumnValue::String(s) = + stmt.get_data(4, CDataType::Default).unwrap().into_owned() + { + types.push(s); + } + } + types.sort(); + assert_eq!(types, vec!["TABLE".to_string(), "VIEW".to_string()]); + } + + #[test] + fn columns_returns_correct_columns() { + let conn = setup_test_db(); + let mut stmt = columns(&conn, None, None, Some("types_test"), None).unwrap(); + + let mut col_names = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + if let ColumnValue::String(n) = + stmt.get_data(4, CDataType::Default).unwrap().into_owned() + { + col_names.push(n); + } + } + assert_eq!(col_names, vec!["id", "val", "label"]); + } + + #[test] + fn columns_filter_by_column_name() { + let conn = setup_test_db(); + let mut stmt = columns(&conn, None, None, Some("types_test"), Some("val")).unwrap(); + + let mut count = 0; + while stmt.fetch().unwrap() == FetchResult::Row { + count += 1; + assert_eq!( + stmt.get_data(4, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("val".to_string()) + ); + } + assert_eq!(count, 1); + } + + #[test] + fn columns_nullable_flag() { + let conn = setup_test_db(); + // empty_table: id INTEGER PRIMARY KEY, name TEXT NOT NULL + // SQLite PRAGMA table_info reports notnull=0 for INTEGER PRIMARY KEY (PK does not imply + // NOT NULL in SQLite's PRAGMA), and notnull=1 for the explicit NOT NULL constraint. + let mut stmt = columns(&conn, None, None, Some("empty_table"), None).unwrap(); + + let mut nullability: Vec<(String, i16)> = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + let col_name = match stmt.get_data(4, CDataType::Default).unwrap().into_owned() { + ColumnValue::String(s) => s, + other => panic!("unexpected column name value: {other:?}"), + }; + let nullable = match stmt.get_data(11, CDataType::Default).unwrap().into_owned() { + ColumnValue::I16(v) => v, + other => panic!("unexpected nullable value: {other:?}"), + }; + nullability.push((col_name, nullable)); + } + + assert_eq!(nullability.len(), 2); + let id_nullable = nullability.iter().find(|(n, _)| n == "id").unwrap().1; + let name_nullable = nullability.iter().find(|(n, _)| n == "name").unwrap().1; + // id INTEGER PRIMARY KEY: PRAGMA notnull=0, so reported as nullable + assert_eq!(id_nullable, i16::from(Nullable::SqlNullable)); + // name TEXT NOT NULL: PRAGMA notnull=1, so reported as not null + assert_eq!(name_nullable, i16::from(Nullable::SqlNoNulls)); + } + + #[test] + fn build_column_row_text_column_has_char_octet_length() { + // sqlite_type_to_sql_data_type() maps every character declared type + // (including VARCHAR) to SqlDataType::EXT_W_VARCHAR, never to the bare + // SqlDataType::VARCHAR, so build_column_row()'s `is_char` compares + // against EXT_W_VARCHAR. CHAR_OCTET_LENGTH (column index 15) must then + // be declared_length * BYTES_PER_CHAR for a text column, not NULL. + let row = build_column_row("t", "label", "VARCHAR(50)", false, 0, None); + assert_eq!(row[15], ColumnValue::I32(50 * BYTES_PER_CHAR)); + } + + #[test] + fn build_column_row_blob_column_has_declared_length_char_octet_length() { + // CHAR_OCTET_LENGTH also applies to binary columns (ODBC 3.0 SQLColumns + // column 16), but a BLOB's declared length is already a byte count and + // must be passed through as-is, not multiplied by BYTES_PER_CHAR. + let row = build_column_row("t", "data", "BLOB(50)", false, 0, None); + assert_eq!(row[15], ColumnValue::I32(50)); + } + + #[test] + fn build_column_row_non_character_non_binary_column_has_null_char_octet_length() { + // INTEGER is neither character nor binary data, so CHAR_OCTET_LENGTH + // is NULL per the ODBC spec. + let row = build_column_row("t", "id", "INTEGER", false, 0, None); + assert_eq!(row[15], ColumnValue::Null); + } + + #[test] + fn build_column_row_huge_declared_length_does_not_overflow() { + // sqlite_declared_type_precision() parses the declared length out of + // the type string. A column declared VARCHAR(2000000000) yields a + // precision whose product with BYTES_PER_CHAR (4) overflows i32 + // (2_000_000_000 * 4 = 8_000_000_000). The checked multiplication + // reports NULL instead of wrapping or panicking. + let row = build_column_row("t", "label", "VARCHAR(2000000000)", false, 0, None); + assert_eq!(row[15], ColumnValue::Null); + } + + #[test] + fn primary_keys_returns_pk_column() { + let conn = setup_test_db(); + let mut stmt = primary_keys(&conn, None, None, Some("parent")).unwrap(); + + let mut pk_cols = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + // Column 3 = TABLE_NAME, Column 4 = COLUMN_NAME, Column 5 = KEY_SEQ + let table = match stmt.get_data(3, CDataType::Default).unwrap().into_owned() { + ColumnValue::String(s) => s, + other => panic!("unexpected table name: {other:?}"), + }; + let col = match stmt.get_data(4, CDataType::Default).unwrap().into_owned() { + ColumnValue::String(s) => s, + other => panic!("unexpected column name: {other:?}"), + }; + let seq = match stmt.get_data(5, CDataType::Default).unwrap().into_owned() { + ColumnValue::I16(v) => v, + other => panic!("unexpected key_seq: {other:?}"), + }; + pk_cols.push((table, col, seq)); + } + assert_eq!(pk_cols.len(), 1); + assert_eq!(pk_cols[0], ("parent".to_string(), "pk".to_string(), 1)); + } + + #[test] + fn primary_keys_no_pk_returns_empty() { + let conn = setup_test_db(); + // types_test has no PRIMARY KEY constraint + let mut stmt = primary_keys(&conn, None, None, Some("types_test")).unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + #[test] + fn foreign_keys_by_fk_table() { + let conn = setup_test_db(); + let mut stmt = foreign_keys( + &conn, + None, + None, + None, // pk table: unfiltered + None, + None, + Some("child"), // fk table: child + ) + .unwrap(); + + let mut fks = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + // Column 3 = PKTABLE_NAME, Column 4 = PKCOLUMN_NAME + // Column 7 = FKTABLE_NAME, Column 8 = FKCOLUMN_NAME + let pk_table = match stmt.get_data(3, CDataType::Default).unwrap().into_owned() { + ColumnValue::String(s) => s, + other => panic!("unexpected pk_table: {other:?}"), + }; + let pk_col = match stmt.get_data(4, CDataType::Default).unwrap().into_owned() { + ColumnValue::String(s) => s, + other => panic!("unexpected pk_col: {other:?}"), + }; + let fk_table = match stmt.get_data(7, CDataType::Default).unwrap().into_owned() { + ColumnValue::String(s) => s, + other => panic!("unexpected fk_table: {other:?}"), + }; + let fk_col = match stmt.get_data(8, CDataType::Default).unwrap().into_owned() { + ColumnValue::String(s) => s, + other => panic!("unexpected fk_col: {other:?}"), + }; + fks.push((pk_table, pk_col, fk_table, fk_col)); + } + assert_eq!(fks.len(), 1); + assert_eq!( + fks[0], + ( + "parent".to_string(), + "pk".to_string(), + "child".to_string(), + "parent_pk".to_string(), + ) + ); + } + + #[test] + fn foreign_keys_no_fk_returns_empty() { + let conn = setup_test_db(); + // parent has no outgoing foreign keys + let mut stmt = foreign_keys(&conn, None, None, None, None, None, Some("parent")).unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + #[test] + fn foreign_keys_by_pk_table() { + let conn = setup_test_db(); + let mut stmt = foreign_keys( + &conn, + None, + None, + Some("parent"), // pk table: parent + None, + None, + None, // fk table: unfiltered + ) + .unwrap(); + + let mut count = 0; + while stmt.fetch().unwrap() == FetchResult::Row { + count += 1; + // FK should point from child.parent_pk to parent.pk + assert_eq!( + stmt.get_data(3, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("parent".to_string()) + ); + assert_eq!( + stmt.get_data(7, CDataType::Default).unwrap().into_owned(), + ColumnValue::String("child".to_string()) + ); + } + assert_eq!(count, 1); + } + + #[test] + fn tables_table_name_honors_escape_character() { + let conn = setup_test_db(); + // `empty\_table` with ESCAPE '\' means a literal underscore: matches + // exactly "empty_table". Without ESCAPE the `_` is a wildcard and the + // stray backslash matches nothing. + let mut stmt = tables(&conn, None, None, Some("empty\\_table"), None).unwrap(); + let mut names = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + if let ColumnValue::String(s) = + stmt.get_data(3, CDataType::Default).unwrap().into_owned() + { + names.push(s); + } + } + assert_eq!(names, vec!["empty_table".to_string()]); + } + + #[test] + fn columns_column_name_is_a_like_pattern() { + let conn = setup_test_db(); + // types_test columns: id, val, label. "%l%" matches val and label. + // Under the old exact-match filter this returned zero rows. + let mut stmt = columns(&conn, None, None, Some("types_test"), Some("%l%")).unwrap(); + let mut names = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + if let ColumnValue::String(s) = + stmt.get_data(4, CDataType::Default).unwrap().into_owned() + { + names.push(s); + } + } + names.sort(); + assert_eq!(names, vec!["label".to_string(), "val".to_string()]); + } + + #[test] + fn tables_table_type_percent_with_table_wildcard_lists_tables() { + let conn = setup_test_db(); + // TableType="%" with TableName="%" is NOT the type-discovery case (that + // requires an empty TableName): it must list actual tables/views. + let mut stmt = tables(&conn, Some(""), Some(""), Some("%"), Some("%")).unwrap(); + let mut names = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + if let ColumnValue::String(s) = + stmt.get_data(3, CDataType::Default).unwrap().into_owned() + { + names.push(s); + } + } + assert!( + names.contains(&"types_test".to_string()), + "expected real table listing, got {names:?}" + ); + } + + fn setup_stats_db() -> SqliteConnection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE t (a INTEGER, b TEXT, c REAL); + CREATE UNIQUE INDEX ux_t_a ON t(a); + CREATE INDEX ix_t_bc ON t(b, c DESC);", + ) + .unwrap(); + SqliteConnection { + conn: Mutex::new(conn), + manual_commit: std::sync::atomic::AtomicBool::new(false), + } + } + + // column ordinals in the 13-column SQLStatistics result set (1-based get_data) + const NON_UNIQUE: u16 = 4; + const TYPE_COL: u16 = 7; + const ORDINAL_POSITION: u16 = 8; + const COLUMN_NAME: u16 = 9; + const ASC_OR_DESC: u16 = 10; + const FILTER_CONDITION: u16 = 13; + + /// (TYPE, NON_UNIQUE, COLUMN_NAME, ORDINAL_POSITION, ASC_OR_DESC) subset of + /// each fetched row, in the order `get_data` is called below. + fn collect_stats( + stmt: &mut SqliteStatement, + ) -> Vec<( + ColumnValue, + ColumnValue, + ColumnValue, + ColumnValue, + ColumnValue, + )> { + let mut out = Vec::new(); + while stmt.fetch().unwrap() == FetchResult::Row { + out.push(( + stmt.get_data(TYPE_COL, CDataType::Default) + .unwrap() + .into_owned(), + stmt.get_data(NON_UNIQUE, CDataType::Default) + .unwrap() + .into_owned(), + stmt.get_data(COLUMN_NAME, CDataType::Default) + .unwrap() + .into_owned(), + stmt.get_data(ORDINAL_POSITION, CDataType::Default) + .unwrap() + .into_owned(), + stmt.get_data(ASC_OR_DESC, CDataType::Default) + .unwrap() + .into_owned(), + )); + } + out + } + + #[test] + fn statistics_reports_table_stat_row_and_indexes_in_order() { + let conn = setup_stats_db(); + let mut stmt = statistics(&conn, None, None, Some("t"), false).unwrap(); + assert_eq!(stmt.column_count(), 13); + let rows = collect_stats(&mut stmt); + // Row 0: table-stat row (TYPE = SQL_TABLE_STAT, NON_UNIQUE NULL, COLUMN_NAME NULL). + assert_eq!(rows[0].0, ColumnValue::I16(SQL_TABLE_STAT)); + assert_eq!(rows[0].1, ColumnValue::Null); + assert_eq!(rows[0].2, ColumnValue::Null); + // Next: the UNIQUE index (NON_UNIQUE = SQL_FALSE = 0) before the non-unique one. + assert_eq!(rows[1].0, ColumnValue::I16(SQL_INDEX_OTHER)); + assert_eq!(rows[1].1, ColumnValue::I16(SQL_FALSE as i16)); + assert_eq!(rows[1].2, ColumnValue::String("a".into())); + // Then the non-unique composite index (b, c DESC): 2 rows, NON_UNIQUE = SQL_TRUE = 1. + assert_eq!(rows[2].1, ColumnValue::I16(1)); + assert_eq!(rows[2].2, ColumnValue::String("b".into())); + assert_eq!(rows[2].3, ColumnValue::I16(1)); // ORDINAL_POSITION + assert_eq!(rows[2].4, ColumnValue::String("A".into())); + assert_eq!(rows[3].2, ColumnValue::String("c".into())); + assert_eq!(rows[3].3, ColumnValue::I16(2)); + assert_eq!(rows[3].4, ColumnValue::String("D".into())); // c DESC + } + + #[test] + fn statistics_unique_only_drops_non_unique_indexes() { + let conn = setup_stats_db(); + let mut stmt = statistics(&conn, None, None, Some("t"), true).unwrap(); + let rows = collect_stats(&mut stmt); + // table-stat row + the unique index's single column only. + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].0, ColumnValue::I16(SQL_TABLE_STAT)); + assert_eq!(rows[1].2, ColumnValue::String("a".into())); + } + + #[test] + fn statistics_table_without_indexes_returns_only_table_stat_row() { + let conn = setup_stats_db(); + conn.conn + .lock() + .unwrap() + .execute_batch("CREATE TABLE plain (x INTEGER);") + .unwrap(); + let mut stmt = statistics(&conn, None, None, Some("plain"), false).unwrap(); + let rows = collect_stats(&mut stmt); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0, ColumnValue::I16(SQL_TABLE_STAT)); + } + + #[test] + fn statistics_with_no_table_returns_empty() { + let conn = setup_stats_db(); + let mut stmt = statistics(&conn, None, None, None, false).unwrap(); + assert_eq!(stmt.column_count(), 13); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + fn setup_partial_index_db() -> SqliteConnection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE tp (a INTEGER, b TEXT); + CREATE INDEX ix_tp_partial ON tp(a) WHERE a > 0;", + ) + .unwrap(); + SqliteConnection { + conn: Mutex::new(conn), + manual_commit: std::sync::atomic::AtomicBool::new(false), + } + } + + #[test] + fn statistics_partial_index_reports_empty_filter_condition() { + let conn = setup_partial_index_db(); + let mut stmt = statistics(&conn, None, None, Some("tp"), false).unwrap(); + // table-stat row + a single index-column row: exactly one index. + assert_eq!(stmt.column_count(), 13); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + // Row 0: table-stat row; skip it. + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + // Row 1: the partial index's single key column. + assert_eq!( + stmt.get_data(FILTER_CONDITION, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::String(String::new()) + ); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + fn setup_expression_index_db() -> SqliteConnection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE te (a INTEGER, b INTEGER); + CREATE INDEX ix_te_expr ON te(a + b);", + ) + .unwrap(); + SqliteConnection { + conn: Mutex::new(conn), + manual_commit: std::sync::atomic::AtomicBool::new(false), + } + } + + #[test] + fn statistics_expression_index_reports_empty_column_name() { + let conn = setup_expression_index_db(); + let mut stmt = statistics(&conn, None, None, Some("te"), false).unwrap(); + // table-stat row + a single index-column row: exactly one index. + assert_eq!(stmt.column_count(), 13); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + // Row 0: table-stat row; skip it. + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + // Row 1: the expression index's key column (key=1, name=NULL). + assert_eq!( + stmt.get_data(COLUMN_NAME, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::String(String::new()) + ); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + fn setup_specialcols_db() -> SqliteConnection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE with_int_pk (id INTEGER PRIMARY KEY, name TEXT); + CREATE TABLE no_pk (a TEXT, b TEXT); + CREATE TABLE without_rowid (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;", + ) + .unwrap(); + SqliteConnection { + conn: Mutex::new(conn), + manual_commit: std::sync::atomic::AtomicBool::new(false), + } + } + + const SC_SCOPE: u16 = 1; + const SC_COLUMN_NAME: u16 = 2; + const SC_DATA_TYPE: u16 = 3; + const SC_BUFFER_LENGTH: u16 = 6; + const SC_PSEUDO_COLUMN: u16 = 8; + + #[test] + fn special_columns_integer_pk_is_reported_as_real_column() { + let conn = setup_specialcols_db(); + let mut stmt = special_columns( + &conn, + IdentifierType::BestRowId, + None, + None, + Some("with_int_pk"), + Scope::CurRow, + Nullable::SqlNullable, + ) + .unwrap(); + assert_eq!(stmt.column_count(), 8); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(SC_COLUMN_NAME, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::String("id".into()) + ); + // A declared INTEGER PRIMARY KEY is the 8-byte 64-bit rowid alias, not + // a plain INTEGER column: DATA_TYPE must be SQL_BIGINT and + // BUFFER_LENGTH must be 8 (not the 19-byte COLUMN_SIZE-derived value + // a generic INTEGER column would get). + assert_eq!( + stmt.get_data(SC_DATA_TYPE, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::I16(SqlDataType::EXT_BIG_INT.0) + ); + assert_eq!( + stmt.get_data(SC_BUFFER_LENGTH, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::I32(8) + ); + assert_eq!( + stmt.get_data(SC_PSEUDO_COLUMN, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::I16(SQL_PC_NOT_PSEUDO) + ); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + #[test] + fn special_columns_rowid_table_reports_rowid_pseudo_column() { + let conn = setup_specialcols_db(); + let mut stmt = special_columns( + &conn, + IdentifierType::BestRowId, + None, + None, + Some("no_pk"), + Scope::CurRow, + Nullable::SqlNullable, + ) + .unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(SC_COLUMN_NAME, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::String("rowid".into()) + ); + assert_eq!( + stmt.get_data(SC_PSEUDO_COLUMN, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::I16(SQL_PC_PSEUDO) + ); + // The volatile rowid pseudo-column only guarantees TRANSACTION scope. + assert_eq!( + stmt.get_data(SC_SCOPE, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::I16(Scope::Transaction.into()) + ); + } + + #[test] + fn special_columns_without_rowid_reports_pk_columns() { + let conn = setup_specialcols_db(); + let mut stmt = special_columns( + &conn, + IdentifierType::BestRowId, + None, + None, + Some("without_rowid"), + Scope::CurRow, + Nullable::SqlNullable, + ) + .unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); + assert_eq!( + stmt.get_data(SC_COLUMN_NAME, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::String("k".into()) + ); + assert_eq!( + stmt.get_data(SC_PSEUDO_COLUMN, CDataType::Default) + .unwrap() + .into_owned(), + ColumnValue::I16(SQL_PC_NOT_PSEUDO) + ); + } + + #[test] + fn special_columns_rowver_is_empty() { + let conn = setup_specialcols_db(); + let mut stmt = special_columns( + &conn, + IdentifierType::RowVer, + None, + None, + Some("with_int_pk"), + Scope::CurRow, + Nullable::SqlNullable, + ) + .unwrap(); + assert_eq!(stmt.column_count(), 8); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } + + #[test] + fn special_columns_requested_session_scope_on_rowid_is_empty() { + // The rowid pseudo-column only guarantees TRANSACTION scope; a request for + // SESSION cannot be met, so the result set is empty (per spec). + let conn = setup_specialcols_db(); + let mut stmt = special_columns( + &conn, + IdentifierType::BestRowId, + None, + None, + Some("no_pk"), + Scope::Session, + Nullable::SqlNullable, + ) + .unwrap(); + assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + } +} diff --git a/src/backend/params.rs b/src/backend/params.rs new file mode 100644 index 0000000..b700a06 --- /dev/null +++ b/src/backend/params.rs @@ -0,0 +1,5 @@ +//! Parameter binding for the SQLite backend is handled inline in `execute.rs` +//! via the rusqlite params API (see `execute`/`prepare`). The generic +//! SQLBindParameter / SQLNumParams / SQLDescribeParam FFI entry points live in +//! stackable-odbc-core (`ffi/params.rs`) and need no SQLite-specific override, so this +//! module is intentionally empty. diff --git a/src/backend/types/connect_params.rs b/src/backend/types/connect_params.rs new file mode 100644 index 0000000..1524713 --- /dev/null +++ b/src/backend/types/connect_params.rs @@ -0,0 +1,45 @@ +//! `SqliteConnectParams`: the SQLite connection settings (the database file +//! path, `:memory:` for an in-memory database) parsed from the generic +//! `stackable-odbc-core` connection-string key/value map. + +use stackable_odbc_core::types::ConnectParams; + +use super::super::SqliteError; + +// --------------------------------------------------------------------------- +// Connection string parameter keys (SQLite-specific) +// --------------------------------------------------------------------------- + +/// Database file path, or `":memory:"` for an in-memory database. +pub(crate) const PARAM_DATABASE: &str = "database"; + +// --------------------------------------------------------------------------- +// Typed connection parameters +// --------------------------------------------------------------------------- + +/// Parsed and validated SQLite connection parameters. +#[derive(Debug)] +pub(crate) struct SqliteConnectParams { + database: String, +} + +impl SqliteConnectParams { + pub fn database(&self) -> &str { + &self.database + } +} + +impl TryFrom<&ConnectParams> for SqliteConnectParams { + type Error = SqliteError; + + fn try_from(params: &ConnectParams) -> Result { + let database = params + .get(PARAM_DATABASE) + .ok_or_else(|| SqliteError::MissingParam { + name: PARAM_DATABASE.into(), + })?; + Ok(SqliteConnectParams { + database: database.to_string(), + }) + } +} diff --git a/src/backend/types/mod.rs b/src/backend/types/mod.rs new file mode 100644 index 0000000..2b87c65 --- /dev/null +++ b/src/backend/types/mod.rs @@ -0,0 +1,3 @@ +//! SQLite-specific types parsed from the ODBC connection string. + +pub(crate) mod connect_params; diff --git a/src/escape_dialect.rs b/src/escape_dialect.rs new file mode 100644 index 0000000..eb124c2 --- /dev/null +++ b/src/escape_dialect.rs @@ -0,0 +1,207 @@ +//! SQLite escape-translation dialect: `"`/`` ` ``/`[...]`-quoted identifiers +//! (SQLite accepts all three quoting styles), bare-string date/time/timestamp +//! literals (SQLite has no date/time storage classes, so a "date" is just a +//! quoted text value), and the `{fn}` scalar-function remap for the names +//! the bundled 3.53.2 build spells differently from ODBC. +//! +//! The remap table is traceable to the `SQL_*_FUNCTIONS` bitmaps +//! `crates/stackable-odbc-sqlite/src/backend/info.rs` advertises for SQLite. +//! Every arm below corresponds to one advertised `SQL_FN_*` +//! bit whose ODBC name SQLite spells differently *and* for which a bare name +//! substitution (`stackable_odbc_core::escape` only ever swaps the identifier in front +//! of the parentheses, it does not rewrite argument syntax or values) still +//! produces valid, semantically equivalent SQLite SQL. +//! +//! - `SQL_FN_STR_UCASE` / `SQL_FN_STR_LCASE`: SQLite's `upper()` / `lower()`. +//! - `SQL_FN_STR_SUBSTRING`: SQLite's `substr(string, start, length)` takes +//! the same argument order and 1-based indexing as ODBC's `SUBSTRING`, so +//! a bare name swap is exact. +//! - `SQL_FN_STR_ASCII`: SQLite's `unicode(x)` returns the code point of the +//! first character of `x`, the same one-argument shape as ODBC's `ASCII`. +//! - `SQL_FN_TD_NOW` / `SQL_FN_TD_CURDATE` / `SQL_FN_TD_CURTIME`: SQLite's +//! `datetime()` / `date()` / `time()` take no arguments and return the +//! current value (see the `SQL_TIMEDATE_FUNCTIONS` doc comment in +//! `backend/info.rs`). They are real callable functions, so `{fn NOW()}` / +//! `{fn CURDATE()}` / `{fn CURTIME()}`'s trailing `()` remains valid SQLite +//! syntax after the name swap. +//! +//! Advertised names that are NOT remapped here, and why: +//! +//! - `SQL_FN_STR_CONCAT`, `LTRIM`, `LENGTH`, `REPLACE`, `RTRIM`, `CHAR`, +//! `SOUNDEX`, `OCTET_LENGTH`; `SQL_FN_NUM_ABS`, `SIGN`, `ROUND`; +//! `SQL_FN_SYS_IFNULL`: SQLite spells every one of these identically to +//! ODBC (case-insensitively): `concat()`, `ltrim()`, `length()`, +//! `replace()`, `rtrim()`, `char()`, `soundex()`, `octet_length()`, +//! `abs()`, `sign()`, `round()`, `ifnull()`, so they pass through +//! unchanged (`None`). SQLite has `ifnull()` natively, so no substitution +//! is needed for `SQL_FN_SYS_IFNULL`. +//! - `SQL_FN_TD_CURRENT_DATE` / `SQL_FN_TD_CURRENT_TIME` / +//! `SQL_FN_TD_CURRENT_TIMESTAMP`: SQLite's `CURRENT_DATE` / `CURRENT_TIME` +//! / `CURRENT_TIMESTAMP` are bare keywords, not callable functions. +//! `SELECT CURRENT_DATE();` is a syntax error (confirmed live: "near '(': +//! syntax error"). The ODBC escape always includes `()` (e.g. +//! `{fn CURRENT_DATE()}`), and the translator appends whatever follows the +//! name verbatim, so no name-only rename can drop that trailing `()`. +use stackable_odbc_core::escape::EscapeDialect; + +/// Remap an ODBC `{fn NAME(...)}` scalar-function name to SQLite's spelling. +/// `None` passes the name through unchanged (same spelling in both). +pub(crate) fn remap_scalar_fn(name: &str) -> Option<&'static str> { + match name.to_ascii_uppercase().as_str() { + // SQL_FN_STR_UCASE / SQL_FN_STR_LCASE + "UCASE" => Some("upper"), + "LCASE" => Some("lower"), + // SQL_FN_STR_SUBSTRING + "SUBSTRING" => Some("substr"), + // SQL_FN_STR_ASCII + "ASCII" => Some("unicode"), + // SQL_FN_TD_NOW / SQL_FN_TD_CURDATE / SQL_FN_TD_CURTIME + "NOW" => Some("datetime"), + "CURDATE" => Some("date"), + "CURTIME" => Some("time"), + _ => None, + } +} + +/// SQLite has no date/time/timestamp storage classes, a date/time value is +/// just quoted text, so `{d/t/ts '...'}` render to the bare string literal +/// with no leading type keyword. +fn render_bare(x: &str) -> String { + x.to_string() +} + +/// SQLite's `EscapeDialect`: all three SQLite identifier-quoting styles +/// (`"`, `` ` ``, `[...]`) and bare-string date/time/timestamp literals. +pub(crate) fn dialect() -> EscapeDialect { + EscapeDialect { + identifier_quotes: &[('"', '"'), ('`', '`'), ('[', ']')], + remap_scalar_fn, + render_date: render_bare, + render_time: render_bare, + render_timestamp: render_bare, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ucase_maps_to_upper() { + assert_eq!(remap_scalar_fn("UCASE"), Some("upper")); + assert_eq!(remap_scalar_fn("ucase"), Some("upper")); + } + + #[test] + fn lcase_maps_to_lower() { + assert_eq!(remap_scalar_fn("LCASE"), Some("lower")); + } + + #[test] + fn substring_maps_to_substr() { + assert_eq!(remap_scalar_fn("SUBSTRING"), Some("substr")); + } + + #[test] + fn ascii_maps_to_unicode() { + assert_eq!(remap_scalar_fn("ASCII"), Some("unicode")); + } + + #[test] + fn now_maps_to_datetime() { + assert_eq!(remap_scalar_fn("NOW"), Some("datetime")); + } + + #[test] + fn curdate_maps_to_date() { + assert_eq!(remap_scalar_fn("CURDATE"), Some("date")); + } + + #[test] + fn curtime_maps_to_time() { + assert_eq!(remap_scalar_fn("CURTIME"), Some("time")); + } + + #[test] + fn abs_passes_through() { + assert_eq!(remap_scalar_fn("ABS"), None); + } + + #[test] + fn concat_passes_through() { + assert_eq!(remap_scalar_fn("CONCAT"), None); + } + + #[test] + fn char_passes_through() { + assert_eq!(remap_scalar_fn("CHAR"), None); + } + + #[test] + fn ifnull_passes_through() { + // SQLite spells IFNULL the same way ODBC does, so it passes through. + assert_eq!(remap_scalar_fn("IFNULL"), None); + } + + #[test] + fn round_passes_through() { + assert_eq!(remap_scalar_fn("ROUND"), None); + } + + #[test] + fn sign_passes_through() { + assert_eq!(remap_scalar_fn("SIGN"), None); + } + + // Deliberately NOT remapped despite being advertised (see module doc). + #[test] + fn current_date_not_remapped() { + assert_eq!(remap_scalar_fn("CURRENT_DATE"), None); + } + + #[test] + fn current_time_not_remapped() { + assert_eq!(remap_scalar_fn("CURRENT_TIME"), None); + } + + #[test] + fn current_timestamp_not_remapped() { + assert_eq!(remap_scalar_fn("CURRENT_TIMESTAMP"), None); + } + + #[test] + fn date_literal_is_bare_string() { + assert_eq!(render_bare("'2020-01-01'"), "'2020-01-01'"); + } + + #[test] + fn time_literal_is_bare_string() { + assert_eq!(render_bare("'10:00:00'"), "'10:00:00'"); + } + + #[test] + fn timestamp_literal_is_bare_string() { + assert_eq!( + render_bare("'2020-01-01 00:00:00'"), + "'2020-01-01 00:00:00'" + ); + } + + #[test] + fn identifier_quotes_include_brackets_and_backticks() { + let d = dialect(); + assert!(d.identifier_quotes.contains(&('[', ']'))); + assert!(d.identifier_quotes.contains(&('`', '`'))); + assert!(d.identifier_quotes.contains(&('"', '"'))); + } + + #[test] + fn end_to_end_fn_and_date_translate() { + let out = stackable_odbc_core::escape::translate_escapes( + "SELECT {fn UCASE(name)} FROM t WHERE d = {d '2020-01-01'}", + &dialect(), + ) + .unwrap(); + assert_eq!(out, "SELECT upper(name) FROM t WHERE d = '2020-01-01'"); + } +} diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs new file mode 100644 index 0000000..34e049c --- /dev/null +++ b/src/ffi_integration_tests.rs @@ -0,0 +1,4581 @@ +//! FFI-level integration tests for the SQLite backend. +//! +//! Exercises the full path: alloc handles -> connect -> exec_direct -> fetch -> +//! get_data -> verify values -> close_cursor -> free handles. + +use std::ffi::c_void; + +use stackable_odbc_core::{ + conformance::{ + all_info_types, genuine_convert_info_types, observe_info_value_kind, observe_u32_value, + }, + ffi, + types::{ + AttrOdbcVersion, CDataType, CompletionType, ConnectionAttribute, Desc, + EnvironmentAttribute, HandleType, HeaderDiagnosticIdentifier, InfoType, Numeric, ParamType, + SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, SQL_CASCADE, SQL_CD_FALSE, SQL_CURSOR_FORWARD_ONLY, + SQL_DIAG_MESSAGE_TEXT, SQL_DRIVER_ODBC_VER_STRING, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, + SQL_GD_BOUND, SQL_IC_SENSITIVE, SQL_INDEX_UNIQUE, SQL_QUICK, SQL_RESTRICT, SqlDataType, + SqlReturn, StatementAttribute, Timestamp, expected_kind, + }, +}; + +use crate::backend::{ + SqliteBackend, + info::{ + SQLITE_AGGREGATE_FUNCTIONS, SQLITE_NUMERIC_FUNCTIONS, SQLITE_SQL92_JOIN_OPERATORS, + SQLITE_SQL92_PREDICATES, SQLITE_SQL92_VALUE_EXPRESSIONS, SQLITE_STRING_FUNCTIONS, + SQLITE_SYSTEM_FUNCTIONS, SQLITE_TIMEDATE_FUNCTIONS, + }, +}; + +/// Helper: allocate env + conn + stmt handles using the SQLite backend. +unsafe fn alloc_handles() -> (*mut c_void, *mut c_void, *mut c_void) { + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + let _ = ffi::handle::sql_alloc_handle::( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + let _ = + ffi::handle::sql_alloc_handle::(HandleType::Dbc as i16, env, &mut conn); + let mut stmt: *mut c_void = std::ptr::null_mut(); + let _ = ffi::handle::sql_alloc_handle::( + HandleType::Stmt as i16, + conn, + &mut stmt, + ); + (env, conn, stmt) + } +} + +/// Helper: connect to an in-memory SQLite database. +unsafe fn connect_memory(conn: *mut c_void) -> SqlReturn { + let input = "Database=:memory:"; + let wide: Vec = input.encode_utf16().collect(); + unsafe { + ffi::connect::sql_driver_connect_w::( + conn, + std::ptr::null_mut(), + wide.as_ptr(), + wide.len() as i16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + 0, + ) + } +} + +/// Helper: execute a SQL statement. +unsafe fn exec_direct(stmt: *mut c_void, sql: &str) -> SqlReturn { + let wide: Vec = sql.encode_utf16().collect(); + unsafe { + ffi::execute::sql_exec_direct_w::(stmt, wide.as_ptr(), wide.len() as i32) + } +} + +/// Helper: free all handles. +unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { + unsafe { + let _ = ffi::handle::sql_free_handle::(HandleType::Stmt as i16, stmt); + let _ = ffi::connect::sql_disconnect::(conn); + let _ = ffi::handle::sql_free_handle::(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::(HandleType::Env as i16, env); + } +} + +#[test] +fn exec_direct_on_connected_handle_succeeds() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Create table and insert data via the connection directly so we can + // use exec_direct for the SELECT through the FFI layer. + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE test (id INTEGER, name TEXT); \ + INSERT INTO test VALUES (1, 'hello'); \ + INSERT INTO test VALUES (2, 'world');", + ) + .expect("setup"); + } + + let ret = exec_direct(stmt, "SELECT id, name FROM test"); + assert_eq!(ret, SqlReturn::SUCCESS); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn exec_direct_not_connected_returns_error() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + // Don't connect — should fail. + let ret = exec_direct(stmt, "SELECT 1"); + assert_eq!(ret, SqlReturn::ERROR); + cleanup(env, conn, stmt); + } +} + +#[test] +fn exec_direct_null_text_returns_error() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + let ret = ffi::execute::sql_exec_direct_w::(stmt, std::ptr::null(), 0); + assert_eq!(ret, SqlReturn::ERROR); + cleanup(env, conn, stmt); + } +} + +#[test] +fn fetch_after_exec_direct_returns_rows_then_no_data() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", + ) + .expect("setup"); + } + + assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); + + // First fetch should return a row. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + // Second fetch should return a row. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + // Third fetch should return NO_DATA. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_data_returns_correct_values() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE t (id INTEGER, name TEXT); INSERT INTO t VALUES (42, 'test');", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT id, name FROM t"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + // Get integer column (SQLite stores as I64) + let mut buf_i64: i64 = 0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut buf_i64 as *mut i64 as *mut c_void, + 8, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(buf_i64, 42); + + // Get string column as WChar + let mut wbuf = [0u16; 20]; + let mut ind2: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 2, + CDataType::WChar as i16, + wbuf.as_mut_ptr() as *mut c_void, + 40, // bytes + &mut ind2, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + let s = String::from_utf16_lossy(&wbuf[..4]); // "test" = 4 chars + assert_eq!(s, "test"); + + cleanup(env, conn, stmt); + } +} + +/// Mirrors `Timestamp` (`SQL_TIMESTAMP_STRUCT`)'s field layout so +/// this test file can read a `SQL_C_TYPE_TIMESTAMP` buffer without adding +/// `odbc-sys` as a direct dependency of this crate (it is only reached today +/// through `stackable-odbc-core`'s re-exports, none of which cover this struct). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RawTimestamp { + year: i16, + month: u16, + day: u16, + hour: u16, + minute: u16, + second: u16, + fraction: u32, +} + +/// SQLite is dynamically typed, and its own documentation defines three storage +/// formats for a `DATETIME` column: ISO-8601 text, an integer count of seconds +/// since the epoch, or a floating point Julian day number. Because this driver +/// describes SQLite `DATE`/`TIME`/`DATETIME`/`TIMESTAMP` columns with the ODBC +/// datetime SQL types, applications request `SQL_C_TYPE_TIMESTAMP` for them, so +/// `write_column_value` must handle all three encodings: a column holding the +/// integer or Julian-day format (which SQLite permits at any time, per-row, +/// since column types are advisory) must not come back as `HY000` "Unsupported +/// conversion". This test exercises all three. +/// +/// This exercises the full FFI path end-to-end (real SQLite storage, real +/// `SQLGetData`) rather than only unit-testing `write_column_value` +/// directly, because the regression was specifically about what a real +/// dynamically-typed column can hold. +#[test] +fn get_data_datetime_column_handles_integer_and_real_storage() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + // SQLite has no real column type enforcement: the column is + // declared DATETIME, but each row is free to store whichever of + // SQLite's own three documented datetime formats it likes. Row 1 + // stores an integer (Unix epoch seconds); row 2 stores a real + // (Julian day number, SQLite's `julianday()` output format). + // + // DATETIME has no substring match in SQLite's column-affinity + // rules (no CHAR/CLOB/TEXT, INT, BLOB, or REAL/FLOA/DOUB), so it + // gets NUMERIC affinity, and NUMERIC affinity silently converts + // an inserted REAL value back to INTEGER when it has no + // fractional part. `2451545.0` would therefore actually be + // stored (and read back) as `ColumnValue::I64`, not `F64`, + // defeating the point of this row: `2451545.5` (2000-01-02 + // 00:00:00 UTC) keeps a fractional part, so SQLite is forced to + // keep it as REAL. + db.execute_batch( + "CREATE TABLE t (id INTEGER, dt DATETIME); \ + INSERT INTO t VALUES (1, 1700000000); \ + INSERT INTO t VALUES (2, 2451545.5);", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT id, dt FROM t ORDER BY id"), + SqlReturn::SUCCESS + ); + + // Row 1: dt stored as INTEGER epoch seconds 1_700_000_000 == + // 2023-11-14 22:13:20 UTC. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + let mut buf = RawTimestamp { + year: 0, + month: 0, + day: 0, + hour: 0, + minute: 0, + second: 0, + fraction: 0, + }; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 2, + CDataType::TypeTimestamp as i16, + &mut buf as *mut RawTimestamp as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "integer-encoded datetime"); + assert_eq!((buf.year, buf.month, buf.day), (2023, 11, 14)); + assert_eq!((buf.hour, buf.minute, buf.second), (22, 13, 20)); + + // Row 2: dt stored as REAL Julian day 2451545.5 == 2000-01-02 + // 00:00:00 UTC (verified against SQLite's own + // `julianday('2000-01-02 00:00:00')`, which returns exactly this + // value — chosen because the Unix-epoch offset it implies, + // 10958.0 days, multiplies back to a whole number of seconds with + // no floating point rounding loss). + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + let mut buf2 = buf; + let mut ind2: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 2, + CDataType::TypeTimestamp as i16, + &mut buf2 as *mut RawTimestamp as *mut c_void, + std::mem::size_of::() as isize, + &mut ind2, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "real-encoded (Julian day) datetime" + ); + assert_eq!((buf2.year, buf2.month, buf2.day), (2000, 1, 2)); + assert_eq!((buf2.hour, buf2.minute, buf2.second), (0, 0, 0)); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_data_col_zero_returns_error() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);") + .expect("setup"); + } + + assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let mut buf: i64 = 0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 0, // bookmark column — not supported + CDataType::SBigInt as i16, + &mut buf as *mut i64 as *mut c_void, + 8, + &mut ind, + ); + assert_eq!(ret, SqlReturn::ERROR); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn num_result_cols_after_exec_direct() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE t (a INTEGER, b TEXT, c REAL)") + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT a, b, c FROM t"), + SqlReturn::SUCCESS + ); + + let mut count: i16 = 0; + let ret = ffi::cursor::sql_num_result_cols::(stmt, &mut count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(count, 3); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn close_cursor_then_fetch_returns_no_data() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);") + .expect("setup"); + } + + assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); + + // Fetch the row + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + // Close cursor — discards the result set entirely. + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::SUCCESS + ); + + // After close_cursor the result set is gone; a new exec_direct is required. + // Calling exec_direct on the same handle must now succeed (no open cursor). + assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn more_results_always_returns_no_data() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + assert_eq!( + ffi::cursor::sql_more_results::(stmt), + SqlReturn::NO_DATA + ); + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_info_returns_dbms_name() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let mut buf = [0u16; 128]; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::( + conn, + InfoType::DbmsName as u16, + buf.as_mut_ptr() as *mut c_void, + 256, // bytes (128 u16s) + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + // str_len is in bytes (SQLGetInfoW spec); convert to u16 count + let result = String::from_utf16_lossy(&buf[..(str_len / 2) as usize]); + assert_eq!(result, "SQLite"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_info_returns_driver_odbc_ver() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let mut buf = [0u16; 128]; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::( + conn, + InfoType::DriverOdbcVer as u16, + buf.as_mut_ptr() as *mut c_void, + 256, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + // str_len is in bytes (SQLGetInfoW spec); convert to u16 count + let result = String::from_utf16_lossy(&buf[..(str_len / 2) as usize]); + assert_eq!(result, SQL_DRIVER_ODBC_VER_STRING); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_info_returns_u32_value() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let mut value: u32 = 0; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::( + conn, + InfoType::GetDataExtensions as u16, + &mut value as *mut u32 as *mut c_void, + 4, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(str_len, 4); + assert_eq!(value, SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND); + + cleanup(env, conn, stmt); + } +} + +/// Asserts `sql_get_info_w` returns exactly `expected` (a `U32`) for `info_type`. +unsafe fn assert_get_info_u32(conn: *mut c_void, info_type: InfoType, expected: u32) { + unsafe { + let mut value: u32 = 0xDEAD_BEEF; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::( + conn, + info_type as u16, + &mut value as *mut u32 as *mut c_void, + 4, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "{info_type:?} must succeed"); + assert_eq!(str_len, 4, "{info_type:?} string_length_ptr"); + assert_eq!( + value, expected, + "{info_type:?} must come from get_info_raw, not the generic default" + ); + } +} + +/// Asserts `sql_get_info_w` returns exactly `expected` (a `U16`) for `info_type`. +unsafe fn assert_get_info_u16(conn: *mut c_void, info_type: InfoType, expected: u16) { + unsafe { + let mut value: u16 = 0xDEAD; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::( + conn, + info_type as u16, + &mut value as *mut u16 as *mut c_void, + 2, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "{info_type:?} must succeed"); + assert_eq!(str_len, 2, "{info_type:?} string_length_ptr"); + assert_eq!( + value, expected, + "{info_type:?} must come from get_info_raw, not the generic default" + ); + } +} + +/// Asserts `sql_get_info_w` returns exactly `expected` (a `String`) for `info_type`. +unsafe fn assert_get_info_str(conn: *mut c_void, info_type: InfoType, expected: &str) { + unsafe { + let mut buf = [0u16; 128]; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::( + conn, + info_type as u16, + buf.as_mut_ptr() as *mut c_void, + 256, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "{info_type:?} must succeed"); + let result = String::from_utf16_lossy(&buf[..(str_len / 2) as usize]); + assert_eq!( + result, expected, + "{info_type:?} must come from get_info_raw, not the generic default" + ); + } +} + +/// Guards the `get_info_raw`-first ordering in `stackable-odbc-core`'s +/// `info_type_default_response` (reached via `sql_get_info_w`). +/// +/// `SqlFileUsage` and `SqlQuotedIdentifierCase` are real (named) +/// `odbc_sys::InfoType` variants, but `sqlite_get_info` has no arm for +/// either and `default_get_info` doesn't cover them either; the only place +/// that produces a real value for them is `common_get_info_raw`, reached +/// through the `get_info_raw` fallback in `sql_get_info_w`. +/// +/// The ten capability bitmaps below (`AggregateFunctions`, `Sql92Predicates`, +/// etc., computed by `SqliteBackend::get_info_raw` in `backend/info.rs`) are +/// the same shape of gap: each is a named `InfoType` with no arm in +/// `sqlite_get_info`'s match, so the only place a real value is ever produced +/// is `get_info_raw`, reached through this same fallback. A unit test that +/// called `get_info_raw` directly would keep passing even if a match-arm +/// ordering mistake or a change to the dispatch order made these arms +/// unreachable through the real FFI dispatch; asserting through +/// `sql_get_info_w` here is what makes that regression fail a test. See the +/// "ordering is load-bearing" note on `info_type_default_response` in +/// `stackable-odbc-core/src/ffi/info.rs`. +#[test] +fn get_info_named_but_unhandled_types_fall_back_to_get_info_raw() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_get_info_u16(conn, InfoType::SqlFileUsage, 0); + assert_get_info_u16(conn, InfoType::SqlQuotedIdentifierCase, SQL_IC_SENSITIVE); + + // SQLite capability bitmaps computed by SqliteBackend::get_info_raw + // (backend/info.rs) -- reference the same constants that function + // returns, rather than restating their numeric values here. + assert_get_info_u32( + conn, + InfoType::AggregateFunctions, + SQLITE_AGGREGATE_FUNCTIONS, + ); + assert_get_info_u32(conn, InfoType::Sql92Predicates, SQLITE_SQL92_PREDICATES); + assert_get_info_u32( + conn, + InfoType::Sql92RelationalJoinOperators, + SQLITE_SQL92_JOIN_OPERATORS, + ); + assert_get_info_u32( + conn, + InfoType::Sql92ValueExpressions, + SQLITE_SQL92_VALUE_EXPRESSIONS, + ); + assert_get_info_u32(conn, InfoType::NumericFunctions, SQLITE_NUMERIC_FUNCTIONS); + assert_get_info_u32(conn, InfoType::StringFunctions, SQLITE_STRING_FUNCTIONS); + assert_get_info_u32(conn, InfoType::SystemFunctions, SQLITE_SYSTEM_FUNCTIONS); + assert_get_info_u32(conn, InfoType::TimedateFunctions, SQLITE_TIMEDATE_FUNCTIONS); + assert_get_info_str(conn, InfoType::LikeEscapeClause, "Y"); + assert_get_info_str(conn, InfoType::OuterJoins, "Y"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_functions_bitmap_has_bits_set() { + use stackable_odbc_core::function_id::{ + FunctionId, SQL_API_ODBC3_ALL_FUNCTIONS, SQL_API_ODBC3_ALL_FUNCTIONS_SIZE, + }; + + /// Check if a function ID is set in the bitmap (mirrors SQL_FUNC_EXISTS macro). + fn func_exists(bitmap: &[u16], func: FunctionId) -> bool { + let fid = func as u16; + let idx = (fid / 16) as usize; + let bit = fid % 16; + bitmap.get(idx).is_some_and(|v| v & (1 << bit) != 0) + } + + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let mut bitmap = [0u16; SQL_API_ODBC3_ALL_FUNCTIONS_SIZE]; + let ret = ffi::info::sql_get_functions::( + conn, + SQL_API_ODBC3_ALL_FUNCTIONS, + bitmap.as_mut_ptr(), + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + assert!( + func_exists(&bitmap, FunctionId::ExecDirect), + "SQLExecDirect" + ); + assert!(func_exists(&bitmap, FunctionId::Fetch), "SQLFetch"); + assert!(func_exists(&bitmap, FunctionId::GetData), "SQLGetData"); + assert!( + func_exists(&bitmap, FunctionId::AllocHandle), + "SQLAllocHandle" + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_functions_single_query() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Check a supported function + let mut result: u16 = 0; + let ret = ffi::info::sql_get_functions::(conn, 9, &mut result); // SQLExecDirect + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(result, 1); + + // Check an unsupported function + let mut result2: u16 = 1; + let ret = ffi::info::sql_get_functions::(conn, 200, &mut result2); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(result2, 0); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_type_info_returns_rows() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let ret = ffi::info::sql_get_type_info::(stmt, 0); + assert_eq!(ret, SqlReturn::SUCCESS); + + // Fetch every row's DATA_TYPE (col 2), and assert on content rather + // than an exact row count; the row list grows as the driver's type + // mapping grows (see the `SQLITE_TYPE_INFO` invariant tests in + // backend/info.rs), so a hardcoded tally breaks on every such + // addition without testing anything meaningful. What actually + // matters to an application going through the C ABI is that the + // result set is non-empty and that the types the mapping most + // commonly produces (SQL_BIGINT for INTEGER columns, SQL_WVARCHAR + // for TEXT columns) are actually present. + let mut data_types = Vec::new(); + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + data_types.push(fetch_i16_col(stmt, 2)); + } + assert!(!data_types.is_empty(), "SQLGetTypeInfo returned no rows"); + assert!( + data_types.contains(&SqlDataType::EXT_BIG_INT.0), + "SQLGetTypeInfo is missing a SQL_BIGINT row: {data_types:?}" + ); + assert!( + data_types.contains(&SqlDataType::EXT_W_VARCHAR.0), + "SQLGetTypeInfo is missing a SQL_WVARCHAR row: {data_types:?}" + ); + + // Verify column count is 19 (standard type info columns) + let mut col_count: i16 = 0; + let ret = ffi::cursor::sql_num_result_cols::(stmt, &mut col_count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(col_count, 19); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_type_info_filters_by_data_type() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Filter for SQL_INTEGER (4) + let ret = ffi::info::sql_get_type_info::(stmt, 4); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut count = 0; + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + count += 1; + } + assert_eq!(count, 1, "Should have exactly 1 INTEGER type row"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn row_count_after_exec_direct() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", + ) + .expect("setup"); + } + + assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); + + let mut count: isize = 0; + let ret = ffi::cursor::sql_row_count::(stmt, &mut count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(count, 2); + + cleanup(env, conn, stmt); + } +} + +/// Helper: set up a connected handle with a test table and view. +unsafe fn setup_metadata_tables(conn: *mut c_void) { + let conn_handle = unsafe { + stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + } + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE test_table (id INTEGER NOT NULL, name TEXT, score REAL); + CREATE VIEW test_view AS SELECT id, name FROM test_table; + INSERT INTO test_table VALUES (1, 'alice', 9.5);", + ) + .expect("setup"); +} + +#[test] +fn sql_tables_w_returns_tables_and_views() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + let ret = ffi::metadata::sql_tables_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + // Count rows + let mut count = 0; + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + count += 1; + } + assert_eq!(count, 2); // test_table + test_view + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_tables_w_with_type_filter() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + let type_filter = "TABLE"; + let type_wide: Vec = type_filter.encode_utf16().collect(); + + let ret = ffi::metadata::sql_tables_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + type_wide.as_ptr(), + type_wide.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut count = 0; + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + count += 1; + } + assert_eq!(count, 1); // only test_table + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_describe_col_w_writes_full_sqlulen_column_size() { + // A real application declares ColumnSize as SQLULEN (8 bytes on 64-bit) + // and does not pre-initialise it. If the driver writes only 4 bytes, the + // high half keeps stack garbage and the application sizes its fetch + // buffer from a corrupted number. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id FROM test_table"), + SqlReturn::SUCCESS + ); + + let mut name_buf = [0u16; 64]; + let mut name_len: i16 = 0; + let mut data_type: i16 = 0; + let mut decimal: i16 = 0; + let mut nullable: i16 = 0; + // odbc_sys::ULen is usize; SQLULEN is 8 bytes on 64-bit. + let mut size: usize = 0xDEAD_BEEF_0000_0000; + + let ret = ffi::metadata::sql_describe_col_w::( + stmt, + 1, + name_buf.as_mut_ptr(), + 64, + &mut name_len, + &mut data_type, + &mut size, + &mut decimal, + &mut nullable, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + size >> 32, + 0, + "high half of the SQLULEN column size was left uninitialised" + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_describe_col_w_after_exec_direct() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id, name FROM test_table"), + SqlReturn::SUCCESS + ); + + // Describe column 1 (id) + let mut name_buf = [0u16; 64]; + let mut name_len: i16 = 0; + let mut data_type: i16 = 0; + let mut size: usize = 0; + let mut decimal: i16 = 0; + let mut nullable: i16 = 0; + + let ret = ffi::metadata::sql_describe_col_w::( + stmt, + 1, + name_buf.as_mut_ptr(), + 64, + &mut name_len, + &mut data_type, + &mut size, + &mut decimal, + &mut nullable, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let name = String::from_utf16_lossy(&name_buf[..name_len as usize]); + assert_eq!(name, "id"); + + // Describe column 2 (name) + let ret = ffi::metadata::sql_describe_col_w::( + stmt, + 2, + name_buf.as_mut_ptr(), + 64, + &mut name_len, + &mut data_type, + &mut size, + &mut decimal, + &mut nullable, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let name = String::from_utf16_lossy(&name_buf[..name_len as usize]); + assert_eq!(name, "name"); + // TEXT maps to SQL_WVARCHAR (the driver reports Unicode character types) + assert_eq!(data_type, SqlDataType::EXT_W_VARCHAR.0); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_col_attribute_w_returns_column_name() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id, name FROM test_table"), + SqlReturn::SUCCESS + ); + + // Get SQL_DESC_NAME (1011) for column 1 + let mut char_buf = [0u16; 64]; + let mut str_len: i16 = 0; + let mut num_attr: isize = 0; + + let ret = ffi::metadata::sql_col_attribute_w::( + stmt, + 1, + Desc::Name as u16, + char_buf.as_mut_ptr() as *mut c_void, + 128, // bytes + &mut str_len, + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + // str_len is in bytes (SQLColAttribute spec); convert to UTF-16 code + // units to index the u16 buffer. + let code_units = usize::try_from(str_len).expect("non-negative length") / 2; + let name = String::from_utf16_lossy(&char_buf[..code_units]); + assert_eq!(name, "id"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_col_attribute_w_returns_type() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id, name FROM test_table"), + SqlReturn::SUCCESS + ); + + // Get SQL_DESC_TYPE (1002) for column 2 (name, TEXT -> SQL_WVARCHAR) + let mut num_attr: isize = 0; + + let ret = ffi::metadata::sql_col_attribute_w::( + stmt, + 2, + Desc::Type as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(num_attr, isize::from(SqlDataType::EXT_W_VARCHAR.0)); // SQL_WVARCHAR + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_col_attribute_w_count() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id, name, score FROM test_table"), + SqlReturn::SUCCESS + ); + + // Get SQL_DESC_COUNT (1001) — column_number is ignored + let mut num_attr: isize = 0; + let ret = ffi::metadata::sql_col_attribute_w::( + stmt, + 0, // ignored for COUNT + 1001, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(num_attr, 3); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_columns_w_returns_column_metadata() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + let table_name = "test_table"; + let table_wide: Vec = table_name.encode_utf16().collect(); + + let ret = ffi::metadata::sql_columns_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + // Count rows — should be 3 columns (id, name, score) + let mut count = 0; + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + count += 1; + } + assert_eq!(count, 3); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_columns_w_result_set_reports_wvarchar_identifiers_and_narrow_data_type() { + // Pins the SQLite driver's routing through the shared catalog descriptor + // constructors (stackable_odbc_core::types::ColumnsResultCol::all_descriptors) + // rather than a hand-built literal. Two properties matter enough to + // assert at the ABI level via SQLDescribeColW: + // - TABLE_NAME (and every identifier column) is SQL_WVARCHAR at width + // 128, not the old SQL_VARCHAR/255 -- the switch the Windows Driver + // Manager is strict about. + // - DATA_TYPE (a SQL_SMALLINT column) has precision 5, not the old 50 + // -- a SMALLINT cannot have 50 digits of precision. + // A regression that reintroduces the old literals in + // crates/stackable-odbc-sqlite/src/backend/metadata.rs would only be caught + // by the Python integration suite without this test. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + let table_name = "test_table"; + let table_wide: Vec = table_name.encode_utf16().collect(); + + let ret = ffi::metadata::sql_columns_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut name_buf = [0u16; 64]; + let mut name_len: i16 = 0; + let mut data_type: i16 = 0; + let mut size: usize = 0; + let mut decimal: i16 = 0; + let mut nullable: i16 = 0; + + // Column 3: TABLE_NAME -- an identifier column. + let ret = ffi::metadata::sql_describe_col_w::( + stmt, + stackable_odbc_core::types::ColumnsResultCol::TableName.pos(), + name_buf.as_mut_ptr(), + 64, + &mut name_len, + &mut data_type, + &mut size, + &mut decimal, + &mut nullable, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(data_type, SqlDataType::EXT_W_VARCHAR.0); + assert_eq!(size, 128); + + // Column 5: DATA_TYPE -- a SQL_SMALLINT column. + let ret = ffi::metadata::sql_describe_col_w::( + stmt, + stackable_odbc_core::types::ColumnsResultCol::DataType.pos(), + name_buf.as_mut_ptr(), + 64, + &mut name_len, + &mut data_type, + &mut size, + &mut decimal, + &mut nullable, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(data_type, SqlDataType::SMALLINT.0); + assert_eq!(size, 5); + + cleanup(env, conn, stmt); + } +} + +// --- DML tests (INSERT / UPDATE / DELETE / DDL) --- + +#[test] +fn exec_direct_create_table_succeeds() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + exec_direct(stmt, "CREATE TABLE dml_test (id INTEGER, val TEXT)"), + SqlReturn::SUCCESS + ); + // No result columns for DDL + let mut col_count: i16 = -1; + assert_eq!( + ffi::cursor::sql_num_result_cols::(stmt, &mut col_count), + SqlReturn::SUCCESS + ); + assert_eq!(col_count, 0); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn exec_direct_insert_then_select_roundtrip() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Set up the table via raw rusqlite so we don't burn statement state. + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") + .expect("setup"); + } + + // INSERT through ODBC — row count must be 1. + assert_eq!( + exec_direct(stmt, "INSERT INTO t VALUES (42, 'hello')"), + SqlReturn::SUCCESS + ); + let mut row_count: isize = -1; + assert_eq!( + ffi::cursor::sql_row_count::(stmt, &mut row_count), + SqlReturn::SUCCESS + ); + assert_eq!(row_count, 1); + + // Close the DML cursor so we can issue the SELECT on the same handle. + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::SUCCESS + ); + + // SELECT — verify the inserted row is readable. + assert_eq!( + exec_direct(stmt, "SELECT id, name FROM t"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let mut id_buf: i64 = 0; + let mut id_len: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut id_buf as *mut i64 as *mut _, + std::mem::size_of::() as isize, + &mut id_len, + ), + SqlReturn::SUCCESS + ); + assert_eq!(id_buf, 42); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn exec_direct_update_returns_correct_row_count() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Seed data via raw rusqlite. + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE t (id INTEGER, v INTEGER); + INSERT INTO t VALUES (1, 10); + INSERT INTO t VALUES (2, 10); + INSERT INTO t VALUES (3, 20);", + ) + .expect("setup"); + } + + // UPDATE two rows through ODBC. + assert_eq!( + exec_direct(stmt, "UPDATE t SET v = 99 WHERE v = 10"), + SqlReturn::SUCCESS + ); + let mut row_count: isize = -1; + let _ = ffi::cursor::sql_row_count::(stmt, &mut row_count); + assert_eq!(row_count, 2); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn exec_direct_delete_returns_correct_row_count() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Seed data via raw rusqlite. + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE t (id INTEGER); + INSERT INTO t VALUES (1); + INSERT INTO t VALUES (2); + INSERT INTO t VALUES (3);", + ) + .expect("setup"); + } + + // DELETE two rows through ODBC. + assert_eq!( + exec_direct(stmt, "DELETE FROM t WHERE id > 1"), + SqlReturn::SUCCESS + ); + let mut row_count: isize = -1; + let _ = ffi::cursor::sql_row_count::(stmt, &mut row_count); + assert_eq!(row_count, 2); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLSetConnectAttrW / SQLGetConnectAttrW tests +// --------------------------------------------------------------------------- + +#[test] +fn set_and_get_connect_attr_autocommit() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::( + conn, + ConnectionAttribute::AUTOCOMMIT.0, + std::ptr::null_mut::(), + 0, + ), + SqlReturn::SUCCESS + ); + + let mut val: u32 = 99; + assert_eq!( + ffi::connect_attr::sql_get_connect_attr_w::( + conn, + 102, + &mut val as *mut u32 as *mut std::ffi::c_void, + 0, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, 0); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_connect_attr_autocommit_default() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let mut val: u32 = 0; + assert_eq!( + ffi::connect_attr::sql_get_connect_attr_w::( + conn, + 102, + &mut val as *mut u32 as *mut std::ffi::c_void, + 0, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, SQL_AUTOCOMMIT_ON as u32); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_connect_attr_connection_dead_is_false() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let mut val: u32 = 99; + assert_eq!( + ffi::connect_attr::sql_get_connect_attr_w::( + conn, + ConnectionAttribute::CONNECTION_DEAD.0, + &mut val as *mut u32 as *mut std::ffi::c_void, + 0, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, SQL_CD_FALSE as u32); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLSetStmtAttrW / SQLGetStmtAttrW tests +// --------------------------------------------------------------------------- + +#[test] +fn set_cursor_type_forward_only_succeeds() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::( + stmt, + StatementAttribute::CursorType as i32, + std::ptr::null_mut::(), + 0, + ), + SqlReturn::SUCCESS + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn set_cursor_type_static_returns_error() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::( + stmt, + StatementAttribute::CursorType as i32, + 3usize as *mut std::ffi::c_void, // SQL_CURSOR_STATIC + 0, + ), + SqlReturn::ERROR + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_cursor_type_default_is_forward_only() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let mut val: u32 = 99; + assert_eq!( + ffi::stmt_attr::sql_get_stmt_attr_w::( + stmt, + StatementAttribute::CursorType as i32, + &mut val as *mut u32 as *mut std::ffi::c_void, + 0, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, SQL_CURSOR_FORWARD_ONLY as u32); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn set_query_timeout_stored_and_retrieved() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let _ = ffi::stmt_attr::sql_set_stmt_attr_w::( + stmt, + StatementAttribute::QueryTimeout as i32, + 30usize as *mut std::ffi::c_void, + 0, + ); + let mut val: u32 = 0; + assert_eq!( + ffi::stmt_attr::sql_get_stmt_attr_w::( + stmt, + 0, + &mut val as *mut u32 as *mut std::ffi::c_void, + 0, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, 30); + + cleanup(env, conn, stmt); + } +} + +// --- SQLEndTran --- + +#[test] +fn end_tran_commit_on_dbc_without_transaction_succeeds() { + // SQLite starts in autocommit; calling SQLEndTran(COMMIT) is a no-op and + // must return SUCCESS. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::tran::sql_end_tran::(HandleType::Dbc as i16, conn, 0), + SqlReturn::SUCCESS + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn end_tran_rollback_on_dbc_without_transaction_succeeds() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::tran::sql_end_tran::(HandleType::Dbc as i16, conn, 1), + SqlReturn::SUCCESS + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn end_tran_commit_on_env_without_transaction_succeeds() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::tran::sql_end_tran::(HandleType::Env as i16, env, 0), + SqlReturn::SUCCESS + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn end_tran_begin_commit_roundtrip() { + // Begin a transaction, insert a row, commit via SQLEndTran, verify it persists. + // Uses raw rusqlite for setup to avoid "cursor already open" on the same stmt handle. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Set up via raw rusqlite: create table, open a transaction, insert a row. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE tran_test(id INTEGER); BEGIN; INSERT INTO tran_test VALUES(42);", + ) + .expect("setup"); + } + + // Commit via SQLEndTran. + assert_eq!( + ffi::tran::sql_end_tran::(HandleType::Dbc as i16, conn, 0), + SqlReturn::SUCCESS + ); + + // Verify row is present via ODBC SELECT. + assert_eq!( + exec_direct(stmt, "SELECT id FROM tran_test"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let mut val: i64 = 0; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut val as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, 42); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn end_tran_begin_rollback_discards_row() { + // Begin a transaction, insert a row, rollback via SQLEndTran — table must be empty. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE tran_rollback(id INTEGER); BEGIN; INSERT INTO tran_rollback VALUES(99);", + ) + .expect("setup"); + } + + // Rollback via SQLEndTran. + assert_eq!( + ffi::tran::sql_end_tran::(HandleType::Dbc as i16, conn, 1), + SqlReturn::SUCCESS + ); + + // Table should be empty. + assert_eq!( + exec_direct(stmt, "SELECT id FROM tran_rollback"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA + ); + + cleanup(env, conn, stmt); + } +} + +// --- SQLFetchScroll --- + +#[test] +fn fetch_scroll_next_advances_cursor() { + // SQL_FETCH_NEXT (1) should behave identically to SQLFetch. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Set up via raw rusqlite to avoid burning statement state. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE scroll_test(v INTEGER); INSERT INTO scroll_test VALUES(1),(2);", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT v FROM scroll_test ORDER BY v"), + SqlReturn::SUCCESS + ); + + let mut val: i64 = 0; + let mut ind: isize = 0; + + // SQL_FETCH_NEXT = 1 + assert_eq!( + ffi::fetch::sql_fetch_scroll::(stmt, 1, 0), + SqlReturn::SUCCESS + ); + let _ = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut val as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ); + assert_eq!(val, 1); + + assert_eq!( + ffi::fetch::sql_fetch_scroll::(stmt, 1, 0), + SqlReturn::SUCCESS + ); + let _ = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut val as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ); + assert_eq!(val, 2); + + assert_eq!( + ffi::fetch::sql_fetch_scroll::(stmt, 1, 0), + SqlReturn::NO_DATA + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn fetch_scroll_non_next_returns_error() { + // SQL_FETCH_FIRST (2) is not supported — must return ERROR (HY106). + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE scroll_err(v INTEGER);") + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT v FROM scroll_err"), + SqlReturn::SUCCESS + ); + + // SQL_FETCH_FIRST = 2 + assert_eq!( + ffi::fetch::sql_fetch_scroll::(stmt, 2, 0), + SqlReturn::ERROR + ); + + cleanup(env, conn, stmt); + } +} + +// --- SQLPrimaryKeysW / SQLForeignKeysW integration tests --- + +/// Helper: create a schema with primary keys and foreign keys. +/// +/// Schema: +/// departments(dept_id PK, dept_name) +/// employees(emp_id PK, name, dept_id FK -> departments(dept_id)) +unsafe fn setup_pk_fk_schema(conn: *mut c_void) { + let conn_handle = unsafe { + stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + } + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE departments (dept_id INTEGER PRIMARY KEY, dept_name TEXT NOT NULL); + CREATE TABLE employees ( + emp_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + dept_id INTEGER REFERENCES departments(dept_id) ON DELETE CASCADE ON UPDATE RESTRICT + );", + ) + .expect("setup pk/fk schema"); +} + +/// Helper: call SQLPrimaryKeysW and collect (table_name, col_name, key_seq) triples. +unsafe fn fetch_primary_keys(stmt: *mut c_void) -> Vec<(String, String, i16)> { + let mut result = Vec::new(); + loop { + let ret = unsafe { ffi::fetch::sql_fetch::(stmt) }; + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS, "fetch for primary keys"); + + // TABLE_NAME = col 3, COLUMN_NAME = col 4, KEY_SEQ = col 5 + let table_name = unsafe { fetch_string_col(stmt, 3) }; + let col_name = unsafe { fetch_string_col(stmt, 4) }; + let key_seq = unsafe { fetch_i16_col(stmt, 5) }; + result.push((table_name, col_name, key_seq)); + } + result +} + +/// Helper: fetch a string column value from the current row. +unsafe fn fetch_string_col(stmt: *mut c_void, col: u16) -> String { + let mut buf = [0u16; 256]; + let mut ind: isize = 0; + let ret = unsafe { + ffi::fetch::sql_get_data::( + stmt, + col, + CDataType::WChar as i16, + buf.as_mut_ptr() as *mut c_void, + (buf.len() * 2) as isize, + &mut ind, + ) + }; + assert_eq!(ret, SqlReturn::SUCCESS, "get_data string col={col}"); + let char_count = if ind > 0 { (ind / 2) as usize } else { 0 }; + String::from_utf16_lossy(&buf[..char_count.min(buf.len())]) +} + +/// Helper: fetch a SMALLINT column value from the current row. +unsafe fn fetch_i16_col(stmt: *mut c_void, col: u16) -> i16 { + let mut val: i16 = 0; + let mut ind: isize = 0; + let ret = unsafe { + ffi::fetch::sql_get_data::( + stmt, + col, + CDataType::SShort as i16, + &mut val as *mut i16 as *mut c_void, + 2, + &mut ind, + ) + }; + assert_eq!(ret, SqlReturn::SUCCESS, "get_data i16 col={col}"); + val +} + +#[test] +fn sql_primary_keys_w_single_pk_column() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_pk_fk_schema(conn); + + let table = "departments"; + let table_wide: Vec = table.encode_utf16().collect(); + let ret = ffi::metadata::sql_primary_keys_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let keys = fetch_primary_keys(stmt); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].0, "departments"); // TABLE_NAME + assert_eq!(keys[0].1, "dept_id"); // COLUMN_NAME + assert_eq!(keys[0].2, 1); // KEY_SEQ + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_primary_keys_w_result_set_has_six_columns() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_pk_fk_schema(conn); + + let table = "departments"; + let table_wide: Vec = table.encode_utf16().collect(); + let ret = ffi::metadata::sql_primary_keys_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut col_count: i16 = 0; + assert_eq!( + ffi::cursor::sql_num_result_cols::(stmt, &mut col_count), + SqlReturn::SUCCESS + ); + assert_eq!(col_count, 6); // TABLE_CAT, TABLE_SCHEM, TABLE_NAME, COLUMN_NAME, KEY_SEQ, PK_NAME + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_primary_keys_w_no_table_filter_returns_all() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_pk_fk_schema(conn); + + // No table filter — should return PKs from both tables. + let ret = ffi::metadata::sql_primary_keys_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let keys = fetch_primary_keys(stmt); + // departments.dept_id + employees.emp_id = 2 PK rows + assert_eq!(keys.len(), 2); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_primary_keys_w_table_with_no_pk_returns_empty() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Create a table without an explicit PRIMARY KEY. + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE no_pk (val TEXT);") + .expect("setup"); + } + + let table = "no_pk"; + let table_wide: Vec = table.encode_utf16().collect(); + let ret = ffi::metadata::sql_primary_keys_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let keys = fetch_primary_keys(stmt); + assert!( + keys.is_empty(), + "expected no PK rows for a table without PK" + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_foreign_keys_w_by_fk_table() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_pk_fk_schema(conn); + + // Query FKs defined on "employees" + let fk_table = "employees"; + let fk_wide: Vec = fk_table.encode_utf16().collect(); + let ret = ffi::metadata::sql_foreign_keys_w::( + stmt, + std::ptr::null(), + 0, // pk_cat + std::ptr::null(), + 0, // pk_schema + std::ptr::null(), + 0, // pk_table + std::ptr::null(), + 0, // fk_cat + std::ptr::null(), + 0, // fk_schema + fk_wide.as_ptr(), + fk_wide.len() as i16, // fk_table + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + // Collect (pktable, pkcolumn, fktable, fkcolumn, key_seq, update_rule, delete_rule) + let mut rows = Vec::new(); + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + let pktable = fetch_string_col(stmt, 3); // PKTABLE_NAME + let pkcolumn = fetch_string_col(stmt, 4); // PKCOLUMN_NAME + let fktable = fetch_string_col(stmt, 7); // FKTABLE_NAME + let fkcolumn = fetch_string_col(stmt, 8); // FKCOLUMN_NAME + let key_seq = fetch_i16_col(stmt, 9); // KEY_SEQ + let update_rule = fetch_i16_col(stmt, 10); // UPDATE_RULE + let delete_rule = fetch_i16_col(stmt, 11); // DELETE_RULE + rows.push(( + pktable, + pkcolumn, + fktable, + fkcolumn, + key_seq, + update_rule, + delete_rule, + )); + } + + assert_eq!(rows.len(), 1, "employees has exactly one FK column"); + let (pktable, pkcolumn, fktable, fkcolumn, key_seq, update_rule, delete_rule) = &rows[0]; + assert_eq!(pktable, "departments"); + assert_eq!(pkcolumn, "dept_id"); + assert_eq!(fktable, "employees"); + assert_eq!(fkcolumn, "dept_id"); + assert_eq!(*key_seq, 1); + assert_eq!(*update_rule, SQL_RESTRICT); + assert_eq!(*delete_rule, SQL_CASCADE); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_foreign_keys_w_by_pk_table() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_pk_fk_schema(conn); + + // Query all FKs that reference "departments" (the PK side) + let pk_table = "departments"; + let pk_wide: Vec = pk_table.encode_utf16().collect(); + let ret = ffi::metadata::sql_foreign_keys_w::( + stmt, + std::ptr::null(), + 0, // pk_cat + std::ptr::null(), + 0, // pk_schema + pk_wide.as_ptr(), + pk_wide.len() as i16, // pk_table + std::ptr::null(), + 0, // fk_cat + std::ptr::null(), + 0, // fk_schema + std::ptr::null(), + 0, // fk_table (omitted → scan all tables) + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut count = 0; + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + count += 1; + } + assert_eq!(count, 1, "exactly one FK references departments"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_foreign_keys_w_result_set_has_fourteen_columns() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_pk_fk_schema(conn); + + let fk_table = "employees"; + let fk_wide: Vec = fk_table.encode_utf16().collect(); + let ret = ffi::metadata::sql_foreign_keys_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + fk_wide.as_ptr(), + fk_wide.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut col_count: i16 = 0; + assert_eq!( + ffi::cursor::sql_num_result_cols::(stmt, &mut col_count), + SqlReturn::SUCCESS + ); + assert_eq!(col_count, 14); // PKTABLE_CAT through DEFERRABILITY + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_foreign_keys_w_no_fk_table_returns_empty_for_no_refs() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Table with no FKs at all. + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE standalone (id INTEGER PRIMARY KEY);") + .expect("setup"); + } + + let pk_table = "standalone"; + let pk_wide: Vec = pk_table.encode_utf16().collect(); + let ret = ffi::metadata::sql_foreign_keys_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + pk_wide.as_ptr(), + pk_wide.len() as i16, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut count = 0; + loop { + let ret = ffi::fetch::sql_fetch::(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + count += 1; + } + assert_eq!(count, 0, "no FK references standalone table"); + + cleanup(env, conn, stmt); + } +} + +// --- SQLNativeSqlW integration tests --- + +#[test] +fn sql_native_sql_w_echoes_sql_unchanged() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let sql = "SELECT id, name FROM t WHERE id = ?"; + let in_wide: Vec = sql.encode_utf16().collect(); + let mut out_buf = [0u16; 128]; + let mut out_len: i32 = 0; + + let ret = ffi::connect::sql_native_sql_w::( + conn, + in_wide.as_ptr(), + in_wide.len() as i32, + out_buf.as_mut_ptr(), + 128, + &mut out_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(out_len as usize, in_wide.len()); + let result = String::from_utf16_lossy(&out_buf[..out_len as usize]); + assert_eq!(result, sql); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_native_sql_w_null_output_buffer_reports_length() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let sql = "SELECT 1"; + let in_wide: Vec = sql.encode_utf16().collect(); + let mut out_len: i32 = 0; + + let ret = ffi::connect::sql_native_sql_w::( + conn, + in_wide.as_ptr(), + in_wide.len() as i32, + std::ptr::null_mut(), + 0, + &mut out_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(out_len as usize, in_wide.len()); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_native_sql_w_truncation_returns_success_with_info() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let sql = "SELECT 1"; // 8 chars + let in_wide: Vec = sql.encode_utf16().collect(); + let mut out_buf = [0u16; 4]; // room for 3 chars + null + let mut out_len: i32 = 0; + + let ret = ffi::connect::sql_native_sql_w::( + conn, + in_wide.as_ptr(), + in_wide.len() as i32, + out_buf.as_mut_ptr(), + 4, + &mut out_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS_WITH_INFO); + // out_len reports full needed length, not truncated length + assert_eq!(out_len as usize, in_wide.len()); + + cleanup(env, conn, stmt); + } +} + +// --- SQLCancel integration tests --- + +#[test] +fn sql_cancel_on_idle_statement_returns_success() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let ret = ffi::cursor::sql_cancel::(stmt); + assert_eq!(ret, SqlReturn::SUCCESS); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_cancel_with_open_cursor_does_not_close_it() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Open a result set. + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE cancel_t (id INTEGER); INSERT INTO cancel_t VALUES (1);", + ) + .expect("setup"); + } + assert_eq!( + exec_direct(stmt, "SELECT id FROM cancel_t"), + SqlReturn::SUCCESS + ); + + // Cancel — no-op, cursor stays open. + assert_eq!( + ffi::cursor::sql_cancel::(stmt), + SqlReturn::SUCCESS + ); + + // Cursor is still open: fetch should succeed. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + cleanup(env, conn, stmt); + } +} + +// --- SQLStatisticsW integration tests --- + +#[test] +fn sql_statistics_w_returns_table_stat_row() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + let table = "test_table"; + let table_wide: Vec = table.encode_utf16().collect(); + let ret = ffi::metadata::sql_statistics_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + SQL_INDEX_UNIQUE, + SQL_QUICK, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + // Result set must have 13 columns (per spec). + let mut col_count: i16 = 0; + assert_eq!( + ffi::cursor::sql_num_result_cols::(stmt, &mut col_count), + SqlReturn::SUCCESS + ); + assert_eq!(col_count, 13); + + // One SQL_TABLE_STAT row for a table with no indexes. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + // No further rows: test_table has no indexes. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_statistics_w_no_table_filter_also_succeeds() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + let ret = ffi::metadata::sql_statistics_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 0, + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + cleanup(env, conn, stmt); + } +} + +// --- SQLSpecialColumnsW integration tests --- + +#[test] +fn sql_special_columns_w_returns_rowid_pseudo_column() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + let table = "test_table"; + let table_wide: Vec = table.encode_utf16().collect(); + let ret = ffi::metadata::sql_special_columns_w::( + stmt, + stackable_odbc_core::types::SQL_BEST_ROWID, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + stackable_odbc_core::types::SQL_SCOPE_CURROW, + stackable_odbc_core::types::Nullable::SqlNullable as u16, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + + // Result set must have 8 columns (per spec). + let mut col_count: i16 = 0; + assert_eq!( + ffi::cursor::sql_num_result_cols::(stmt, &mut col_count), + SqlReturn::SUCCESS + ); + assert_eq!(col_count, 8); + + // test_table is a rowid table with no declared PRIMARY KEY, so + // BEST_ROWID on a rowid table returns the rowid pseudo-column. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_statistics_w_not_connected_returns_error() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + // no connect + let ret = ffi::metadata::sql_statistics_w::( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 0, + 0, + ); + assert_eq!(ret, SqlReturn::ERROR); + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_special_columns_w_not_connected_returns_error() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + // no connect + let ret = ffi::metadata::sql_special_columns_w::( + stmt, + 1, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 0, + 0, + ); + assert_eq!(ret, SqlReturn::ERROR); + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P1: SQLGetData truncation +// --------------------------------------------------------------------------- + +#[test] +fn get_data_truncates_string_returns_success_with_info() { + // Verifies that reading a string column into a buffer that is too small + // returns SUCCESS_WITH_INFO (SQLSTATE 01004) and writes the truncated value. + // Buffer holds 4 u16 slots (8 bytes): capacity for 3 chars + null terminator. + // Full string "hello" is 5 chars → truncated to "hel\0". + // ind is set to the full byte count: 5 chars × 2 bytes = 10. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE trunc_test (id INTEGER, name TEXT); \ + INSERT INTO trunc_test VALUES (1, 'hello');", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT name FROM trunc_test"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + // 4 u16 slots = 8 bytes → capacity for 3 chars + null terminator. + let mut wbuf = [0u16; 4]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::WChar as i16, + wbuf.as_mut_ptr() as *mut c_void, + 8, // bytes + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS_WITH_INFO); + // ind reports the full byte count of the original string (no null). + assert_eq!(ind, 10); // 5 chars × 2 bytes + // Buffer contains "hel\0". + assert_eq!(String::from_utf16_lossy(&wbuf[..3]), "hel"); + assert_eq!(wbuf[3], 0u16); // null terminator + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P1: Fetch after NO_DATA returns NO_DATA again (not ERROR) +// --------------------------------------------------------------------------- + +#[test] +fn fetch_after_no_data_returns_no_data_again() { + // After a result set is exhausted (SQLFetch returns NO_DATA), subsequent + // SQLFetch calls must also return NO_DATA — not ERROR or panic. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE one_row (v INTEGER); INSERT INTO one_row VALUES (1);") + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT v FROM one_row"), + SqlReturn::SUCCESS + ); + + // Fetch the single row. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + // Cursor exhausted. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA + ); + // A second call past the end must still return NO_DATA, not ERROR. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA + ); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P1: Statement handle is reusable after an error +// --------------------------------------------------------------------------- + +#[test] +fn exec_direct_reuse_after_error() { + // After a failed exec_direct (invalid SQL → SQL_ERROR), the same statement + // handle must accept a valid query and succeed. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Invalid SQL — must fail. + assert_eq!(exec_direct(stmt, "NOT VALID SQL AT ALL"), SqlReturn::ERROR); + + // Valid query on the same handle — must succeed. + assert_eq!(exec_direct(stmt, "SELECT 1"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + let mut val: i64 = 0; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut val as *mut i64 as *mut c_void, + 8, + &mut ind, + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, 1); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P2: SQLColAttributeW — nullable, precision, octet_length via FFI +// --------------------------------------------------------------------------- + +#[test] +fn sql_col_attribute_w_returns_nullable() { + // SQL_DESC_NULLABLE (1008): our SQLite backend always reports nullable=1 + // (all columns nullable). This test documents that current behaviour. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id, name FROM test_table"), + SqlReturn::SUCCESS + ); + + for col in [1u16, 2u16] { + let mut num_attr: isize = 99; + let ret = ffi::metadata::sql_col_attribute_w::( + stmt, + col, + Desc::Nullable as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "col {col}"); + // SQLite backend always reports nullable=1 (SQL_NULLABLE). + assert_eq!(num_attr, 1, "col {col} should be nullable"); + } + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_col_attribute_w_returns_precision_for_integer() { + // SQL_DESC_PRECISION (1005): INTEGER maps to EXT_BIG_INT with precision=19. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id FROM test_table"), + SqlReturn::SUCCESS + ); + + let mut num_attr: isize = 0; + let ret = ffi::metadata::sql_col_attribute_w::( + stmt, + 1, + Desc::Precision as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + // INTEGER → EXT_BIG_INT → BIGINT_COLUMN_SIZE = 19 + assert_eq!(num_attr, 19); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn sql_col_attribute_w_returns_octet_length_for_integer() { + // SQL_DESC_OCTET_LENGTH (1013): INTEGER (EXT_BIG_INT) = 8 bytes. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + assert_eq!( + exec_direct(stmt, "SELECT id FROM test_table"), + SqlReturn::SUCCESS + ); + + let mut num_attr: isize = 0; + let ret = ffi::metadata::sql_col_attribute_w::( + stmt, + 1, + Desc::OctetLength as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + // EXT_BIG_INT → OCTET_LENGTH_BIGINT = 8 + assert_eq!(num_attr, 8); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P2: SQLCloseCursor called twice returns 24000 on the second call +// --------------------------------------------------------------------------- + +#[test] +fn close_cursor_twice_returns_error() { + // The second SQLCloseCursor call must return ERROR (SQLSTATE 24000 — invalid + // cursor state) because there is no open cursor after the first close. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE cc_test (v INTEGER); INSERT INTO cc_test VALUES (1);") + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT v FROM cc_test"), + SqlReturn::SUCCESS + ); + + // First close — cursor is open, must succeed. + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::SUCCESS + ); + // Second close — no cursor open, must return ERROR (24000). + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::ERROR + ); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P2: SQLNumResultCols after SQLPrepare but before SQLExecute +// --------------------------------------------------------------------------- + +#[test] +fn num_result_cols_after_prepare_before_execute() { + // After SQLPrepare (but before SQLExecute), SQLNumResultCols must return + // SUCCESS. The SQLite backend returns count=0 because column metadata is + // only populated after execute. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let sql = "SELECT 1"; + let wide: Vec = sql.encode_utf16().collect(); + let ret = + ffi::execute::sql_prepare_w::(stmt, wide.as_ptr(), wide.len() as i32); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut count: i16 = 99; + let ret = ffi::cursor::sql_num_result_cols::(stmt, &mut count); + assert_eq!(ret, SqlReturn::SUCCESS); + // Column metadata is populated only after execute; before execute the + // SQLite backend reports 0 columns. + assert_eq!(count, 0); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P3: SQLGetDiagFieldW — field-by-field after an error +// --------------------------------------------------------------------------- + +#[test] +fn get_diag_field_number_after_error() { + // SQL_DIAG_NUMBER (2) on the header record (rec_number=0) reports the count + // of diagnostic records. After one error it must be 1. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + let mut count: i32 = 0; + let ret = ffi::diag::sql_get_diag_field_w::( + HandleType::Stmt as i16, + stmt, + 0, // header field: rec_number = 0 + HeaderDiagnosticIdentifier::Number as i16, + &mut count as *mut i32 as *mut c_void, + 0, + std::ptr::null_mut(), + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(count, 1, "one diagnostic record after one error"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_diag_field_sqlstate_after_error() { + // SQL_DIAG_SQLSTATE (4) on rec_number=1 returns the 5-character SQLSTATE. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + // 6 u16 slots: 5 SQLSTATE chars + null terminator = 12 bytes. + let mut state_buf = [0u16; 6]; + let mut str_len: i16 = 0; + let ret = ffi::diag::sql_get_diag_field_w::( + HandleType::Stmt as i16, + stmt, + 1, // first record + HeaderDiagnosticIdentifier::SqlState as i16, + state_buf.as_mut_ptr() as *mut c_void, + 12, // buffer_length in bytes (6 u16s) + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + // SQLSTATE is always exactly 5 characters = 10 bytes; StringLengthPtr + // is spec'd in bytes for SQLGetDiagField. + assert_eq!(str_len, 10); + let state = String::from_utf16_lossy(&state_buf[..5]); + // Must be a non-empty, 5-char SQLSTATE string. + assert_eq!(state.len(), 5, "SQLSTATE must be 5 chars"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_diag_field_native_error_after_error() { + // SQL_DIAG_NATIVE (5) returns the driver-specific native error code (i32). + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + let mut native: i32 = -999; + let mut str_len: i16 = 0; + let ret = ffi::diag::sql_get_diag_field_w::( + HandleType::Stmt as i16, + stmt, + 1, // first record + HeaderDiagnosticIdentifier::Native as i16, + &mut native as *mut i32 as *mut c_void, + 0, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(str_len, 4); // i32 = 4 bytes + // Native error is driver-defined; we just verify the field is readable. + let _ = native; + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_diag_field_message_text_after_error() { + // SQL_DIAG_MESSAGE_TEXT (6) returns the diagnostic message string. + // After an invalid-SQL error the message must be non-empty. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + let mut msg_buf = [0u16; 256]; + let mut str_len: i16 = 0; + let buffer_length = + i16::try_from(std::mem::size_of_val(&msg_buf)).expect("msg_buf byte size fits in i16"); + let ret = ffi::diag::sql_get_diag_field_w::( + HandleType::Stmt as i16, + stmt, + 1, // first record + SQL_DIAG_MESSAGE_TEXT, + msg_buf.as_mut_ptr() as *mut c_void, + buffer_length, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert!(str_len > 0, "diagnostic message must be non-empty"); + // str_len is a BYTE count (SQLGetDiagField spec); convert to UTF-16 + // code units and clamp to the buffer's element count before indexing; + // the untruncated byte count can exceed the buffer capacity. + let code_units = + (usize::try_from(str_len).expect("non-negative length") / 2).min(msg_buf.len()); + let msg = String::from_utf16_lossy(&msg_buf[..code_units]); + assert!(!msg.is_empty(), "message text must not be empty"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn get_diag_field_message_text_long_message_does_not_panic() { + // Regression test: SQLGetDiagField's StringLengthPtr is a BYTE count, not + // a UTF-16 code-unit count. A caller that indexes a u16 message buffer + // with the raw byte count panics once the message exceeds half the + // buffer's element count (128 characters for a 256-element buffer). Force + // a diagnostic message longer than 128 characters via an overlong invalid + // table name, and confirm retrieval succeeds without an out-of-bounds + // panic. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let long_identifier = "x".repeat(200); + let sql = format!("SELECT * FROM {long_identifier}"); + assert_eq!(exec_direct(stmt, &sql), SqlReturn::ERROR); + + let mut msg_buf = [0u16; 256]; + let mut str_len: i16 = 0; + let buffer_length = + i16::try_from(std::mem::size_of_val(&msg_buf)).expect("msg_buf byte size fits in i16"); + let ret = ffi::diag::sql_get_diag_field_w::( + HandleType::Stmt as i16, + stmt, + 1, + SQL_DIAG_MESSAGE_TEXT, + msg_buf.as_mut_ptr() as *mut c_void, + buffer_length, + &mut str_len, + ); + assert!( + matches!(ret, SqlReturn::SUCCESS | SqlReturn::SUCCESS_WITH_INFO), + "expected SUCCESS or SUCCESS_WITH_INFO, got {ret:?}" + ); + assert!(str_len > 0, "diagnostic message must be non-empty"); + assert!( + str_len as usize > 128 * 2, + "test setup must produce a message over 128 UTF-16 code units; got {str_len} bytes" + ); + + // This conversion must divide by 2: `str_len` is a byte count (> 256 + // here), so `str_len as usize` without the division would index past + // the end of the 256-element buffer. + let code_units = + (usize::try_from(str_len).expect("non-negative length") / 2).min(msg_buf.len()); + let msg = String::from_utf16_lossy(&msg_buf[..code_units]); + assert!( + msg.contains(&long_identifier), + "message should contain the overlong identifier: {msg}" + ); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// P3: SQLGetEnvAttrW — ODBC version roundtrip +// --------------------------------------------------------------------------- + +#[test] +fn get_env_attr_odbc_version_roundtrip() { + // Set SQL_ATTR_ODBC_VERSION (200) to SQL_OV_ODBC3 (3), then read it back. + // Per spec HY010, SQLSetEnvAttr must be called before any connection handle + // is allocated on the environment. We therefore use a bare env handle here. + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ), + SqlReturn::SUCCESS + ); + + // Set SQL_ATTR_ODBC_VERSION = SQL_OV_ODBC3 (3) before any conn is allocated. + assert_eq!( + ffi::env::sql_set_env_attr::( + env, + EnvironmentAttribute::OdbcVersion as i32, + AttrOdbcVersion::Odbc3 as usize as *mut c_void, + 0, + ), + SqlReturn::SUCCESS + ); + + // Read it back. + let mut version: i32 = 0; + let mut str_len: i32 = 0; + assert_eq!( + ffi::env::sql_get_env_attr::( + env, + EnvironmentAttribute::OdbcVersion as i32, + &mut version as *mut i32 as *mut c_void, + 4, + &mut str_len, + ), + SqlReturn::SUCCESS + ); + assert_eq!(version, AttrOdbcVersion::Odbc3 as i32); + assert_eq!(str_len, 4); // sizeof(i32) + + let _ = ffi::handle::sql_free_handle::(HandleType::Env as i16, env); + } +} + +// --------------------------------------------------------------------------- +// Array-fetch path (SQLBindCol + SQLFetch) +// --------------------------------------------------------------------------- +// +// pyodbc retrieves column data via SQLGetData after each fetch; turbodbc and +// other drivers that pre-allocate column buffers use SQLBindCol + SQLFetch +// instead. These tests exercise the bound-column path so regressions in +// sql_bind_col or the write_column_value call inside sql_fetch are caught +// independently of the sql_get_data path. + +#[test] +fn bind_col_and_fetch_reads_bound_column_values() { + // Exercises SQL_ATTR_ROW_ARRAY_SIZE (27) and SQL_ATTR_ROWS_FETCHED_PTR (26) + // attribute setting (accepted without error) plus the full SQLBindCol → + // SQLFetch data path. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Insert rows via rusqlite directly. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE bind_col_test (id INTEGER); \ + INSERT INTO bind_col_test VALUES (10); \ + INSERT INTO bind_col_test VALUES (20); \ + INSERT INTO bind_col_test VALUES (30);", + ) + .expect("setup"); + } + + // Set SQL_ATTR_ROW_ARRAY_SIZE = 1. + // The driver supports single-row fetch only; setting this to 1 must + // succeed and must not change the observed per-fetch row count. + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::( + stmt, + StatementAttribute::RowArraySize as i32, + std::ptr::without_provenance_mut(1usize), // 1 row per fetch + 0, + ), + SqlReturn::SUCCESS + ); + + // Set SQL_ATTR_ROWS_FETCHED_PTR to a usize variable. + // Stored in stmt.attrs; the attribute must be accepted without error. + let mut rows_fetched: usize = 0; + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::( + stmt, + StatementAttribute::RowsFetchedPtr as i32, + &mut rows_fetched as *mut usize as *mut c_void, + 0, + ), + SqlReturn::SUCCESS + ); + + // Execute the SELECT. + assert_eq!( + exec_direct(stmt, "SELECT id FROM bind_col_test ORDER BY id"), + SqlReturn::SUCCESS + ); + + // Bind column 1 to an i64 buffer via SQLBindCol. + let mut id_buf: i64 = 0; + let mut id_ind: isize = 0; + assert_eq!( + ffi::bind::sql_bind_col::( + stmt, + 1, // column 1 + CDataType::SBigInt as i16, + &mut id_buf as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + &mut id_ind, + ), + SqlReturn::SUCCESS + ); + + // Fetch each row and verify the bound buffer is populated. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(id_buf, 10); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(id_buf, 20); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(id_buf, 30); + + // Result set exhausted. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn fetch_truncating_bound_column_reports_01004() { + // Spec: "If the data is truncated because the length of the data buffer is + // too small ... SQLFetch returns SQLSTATE 01004 (Data truncated) and + // SQL_SUCCESS_WITH_INFO." Silently returning SQL_SUCCESS would leave the + // application reading truncated data believing it complete. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE trunc_test (s TEXT); + INSERT INTO trunc_test VALUES ('abcdef');", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT s FROM trunc_test"), + SqlReturn::SUCCESS + ); + + // Four bytes holds three characters plus the null terminator. + let mut buf = [0u8; 4]; + let mut ind: isize = 0; + assert_eq!( + ffi::bind::sql_bind_col::( + stmt, + 1, + CDataType::Char as i16, + buf.as_mut_ptr().cast(), + buf.len() as isize, + &mut ind, + ), + SqlReturn::SUCCESS + ); + + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS_WITH_INFO, + "truncation was reported as plain SQL_SUCCESS" + ); + + // The indicator reports the untruncated length, per step 6 of the spec. + assert_eq!(ind, 6); + assert_eq!(&buf[..3], b"abc"); + assert_eq!(buf[3], 0, "result was not null-terminated"); + + // A 01004 diagnostic must be retrievable. + let mut state = [0u16; 6]; + let mut native: i32 = 0; + let mut msg = [0u16; 256]; + let mut msg_len: i16 = 0; + assert_eq!( + ffi::diag::sql_get_diag_rec_w::( + HandleType::Stmt as i16, + stmt, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + msg.len() as i16, + &mut msg_len, + ), + SqlReturn::SUCCESS, + "no diagnostic record was pushed" + ); + let sqlstate = String::from_utf16_lossy(&state[..5]); + assert_eq!(sqlstate, "01004"); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn autocommit_off_then_rollback_discards_changes() { + // Turn autocommit off, insert rows, roll back: the rollback must discard + // the inserted rows. This asserts the autocommit attribute reaches the + // backend rather than being stored locally while every row is committed + // as it is written. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE tx_test (id INTEGER);") + .expect("setup"); + } + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::( + conn, + ConnectionAttribute::AUTOCOMMIT.0, + std::ptr::without_provenance_mut(SQL_AUTOCOMMIT_OFF), + 0, + ), + SqlReturn::SUCCESS, + "SQLite advertises SQL_TC_DML so manual-commit must be accepted" + ); + + assert_eq!( + exec_direct(stmt, "INSERT INTO tx_test VALUES (1)"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::SUCCESS + ); + assert_eq!( + exec_direct(stmt, "INSERT INTO tx_test VALUES (2)"), + SqlReturn::SUCCESS + ); + + assert_eq!( + ffi::tran::sql_end_tran::( + HandleType::Dbc as i16, + conn, + CompletionType::Rollback as i16, + ), + SqlReturn::SUCCESS + ); + + let count: i64 = { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.query_row("SELECT COUNT(*) FROM tx_test", [], |r| r.get(0)) + .expect("count") + }; + assert_eq!(count, 0, "rollback did not discard the inserted rows"); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// Batch parameter path (SQLBindParameter + SQLPrepare + SQLExecute) +// --------------------------------------------------------------------------- +// +// pyodbc uses SQLExecDirect for most inserts; turbodbc (and PowerBI) use the +// prepare/bind/execute path for parameterised DML. This test exercises the +// full SQLBindParameter → SQLExecute pipeline including SQL_ATTR_PARAMSET_SIZE. + +#[test] +fn bind_parameter_prepare_execute_inserts_row() { + // Exercises SQL_ATTR_PARAMSET_SIZE (22) attribute setting (accepted without + // error) plus the full SQLBindParameter → SQLPrepare → SQLExecute DML path. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Create the target table via rusqlite. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE bind_param_test (id INTEGER);") + .expect("setup"); + } + + // Set SQL_ATTR_PARAMSET_SIZE = 1. + // The driver executes one parameter row at a time; setting this to 1 + // must succeed without error. + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::( + stmt, + StatementAttribute::ParamsetSize as i32, + std::ptr::without_provenance_mut(1usize), // 1 parameter row per execute + 0, + ), + SqlReturn::SUCCESS + ); + + // Prepare the INSERT statement. + let sql = "INSERT INTO bind_param_test VALUES (?)"; + let wide: Vec = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + // Bind parameter 1 (the `?` placeholder) to an i64 buffer holding 42. + let mut val: i64 = 42; + assert_eq!( + ffi::params::sql_bind_parameter::( + stmt, + 1, // parameter_number + ParamType::Input as i16, + CDataType::SBigInt as i16, // value_type: SQL_C_SBIGINT + SqlDataType::EXT_BIG_INT.0, + 19, // column_size (max digits of i64) + 0, // decimal_digits + &mut val as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + + // Execute the prepared INSERT. + assert_eq!( + ffi::execute::sql_execute::(stmt), + SqlReturn::SUCCESS + ); + + // Verify via rusqlite that exactly one row with value 42 was inserted. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + let count: i64 = db + .query_row( + "SELECT COUNT(*) FROM bind_param_test WHERE id = 42", + [], + |r| r.get(0), + ) + .expect("count query"); + assert_eq!(count, 1); + } + + cleanup(env, conn, stmt); + } +} + +#[test] +fn exec_direct_sends_bound_parameters() { + // SQLExecDirect must send bound parameter values, not the literal `?`. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE exec_direct_params (id INTEGER); + INSERT INTO exec_direct_params VALUES (1), (2), (3);", + ) + .expect("setup"); + } + + let mut val: i64 = 2; + assert_eq!( + ffi::params::sql_bind_parameter::( + stmt, + 1, + ParamType::Input as i16, + CDataType::SBigInt as i16, + SqlDataType::EXT_BIG_INT.0, + 19, + 0, + &mut val as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + + let sql = "SELECT id FROM exec_direct_params WHERE id = ?"; + let wide: Vec = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_exec_direct_w::( + stmt, + wide.as_ptr(), + wide.len() as i32 + ), + SqlReturn::SUCCESS, + "SQLExecDirect rejected the parameterised statement" + ); + + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS, + "no row returned — the bound parameter was not sent" + ); + let mut out: i64 = 0; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut out as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ), + SqlReturn::SUCCESS + ); + assert_eq!(out, 2, "wrong row: parameter value was not applied"); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::NO_DATA, + "expected exactly one matching row" + ); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn bind_timestamp_and_numeric_params_are_stored_not_nulled() { + // A bound SQL_C_TYPE_TIMESTAMP and SQL_C_NUMERIC must reach the backend as + // their real values. Before read_param_value handled the temporal/numeric + // C structs, both marshalled to NULL and the INSERT silently lost the data. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE dt_test (ts TEXT, amount TEXT);") + .expect("setup"); + } + + let sql = "INSERT INTO dt_test VALUES (?, ?)"; + let wide: Vec = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + let mut ts = Timestamp { + year: 2024, + month: 1, + day: 2, + hour: 10, + minute: 30, + second: 15, + fraction: 123_000_000, + }; + assert_eq!( + ffi::params::sql_bind_parameter::( + stmt, + 1, + ParamType::Input as i16, + CDataType::TypeTimestamp as i16, + SqlDataType::TIMESTAMP.0, + 23, + 9, + &mut ts as *mut _ as *mut c_void, + std::mem::size_of::() as isize, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + + // -123.45 as SQL_NUMERIC_STRUCT: mantissa 12345 (LE), scale 2, sign 0. + let mut val_bytes = [0u8; 16]; + val_bytes[..16].copy_from_slice(&12_345u128.to_le_bytes()); + let mut num = Numeric { + precision: 5, + scale: 2, + sign: 0, + val: val_bytes, + }; + assert_eq!( + ffi::params::sql_bind_parameter::( + stmt, + 2, + ParamType::Input as i16, + CDataType::Numeric as i16, + SqlDataType::DECIMAL.0, + 5, + 2, + &mut num as *mut _ as *mut c_void, + std::mem::size_of::() as isize, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + + assert_eq!( + ffi::execute::sql_execute::(stmt), + SqlReturn::SUCCESS + ); + + let (ts_stored, amount_stored): (String, String) = { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.query_row("SELECT ts, amount FROM dt_test", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .expect("row") + }; + assert_eq!(ts_stored, "2024-01-02 10:30:15.123000000"); + assert_eq!(amount_stored, "-123.45"); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLBulkOperations / SQLSetPos +// --------------------------------------------------------------------------- + +#[test] +fn bulk_operations_returns_hyc00() { + // SQLBulkOperations is not supported by this driver. It must return ERROR + // with SQLSTATE HYC00 (optional feature not implemented) even when a cursor + // is open. + use stackable_odbc_core::types::SQL_ADD; + + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Open a cursor so the handle is in a valid statement state. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE bulkops_test (id INTEGER, val TEXT);") + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT id, val FROM bulkops_test"), + SqlReturn::SUCCESS + ); + + let ret = ffi::cursor::sql_bulk_operations::(stmt, SQL_ADD); + assert_eq!(ret, SqlReturn::ERROR); + + cleanup(env, conn, stmt); + } +} + +#[test] +fn set_pos_returns_hyc00() { + // SQLSetPos is not supported by this driver. It must return ERROR with + // SQLSTATE HYC00 even when a cursor is open. + use stackable_odbc_core::types::{SQL_LOCK_NO_CHANGE, SQL_POSITION}; + + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Open a cursor so the handle is in a valid statement state. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE setpos_test (id INTEGER); INSERT INTO setpos_test VALUES (1);", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT id FROM setpos_test"), + SqlReturn::SUCCESS + ); + // Advance to first row so the cursor is positioned. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let ret = + ffi::cursor::sql_set_pos::(stmt, 1, SQL_POSITION, SQL_LOCK_NO_CHANGE); + assert_eq!(ret, SqlReturn::ERROR); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// Data-at-execution (SQLParamData / SQLPutData) +// --------------------------------------------------------------------------- + +#[test] +fn data_at_execution_insert() { + // Exercises the full SQLParamData / SQLPutData flow for a data-at-execution + // parameter: + // 1. Prepare INSERT with two parameters. + // 2. Bind param 1 (id) normally as SQL_C_SLONG. + // 3. Bind param 2 (name) as SQL_DATA_AT_EXEC. + // 4. SQLExecute → SQL_NEED_DATA. + // 5. SQLParamData → SQL_NEED_DATA (returns token for param 2). + // 6. SQLPutData with "hello". + // 7. SQLParamData → SQL_SUCCESS (executes the INSERT). + // 8. Verify the row in the database. + use stackable_odbc_core::types::SQL_DATA_AT_EXEC; + + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Create target table. + { + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch("CREATE TABLE dae_test (id INTEGER, name TEXT);") + .expect("setup"); + } + + // Prepare the INSERT. + let sql = "INSERT INTO dae_test VALUES (?, ?)"; + let wide: Vec = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + // Bind param 1 (id) as a normal integer. + let mut id_val: i32 = 42; + let mut id_ind: isize = 0; + assert_eq!( + ffi::params::sql_bind_parameter::( + stmt, + 1, // parameter_number + ParamType::Input as i16, + CDataType::SLong as i16, // SQL_C_SLONG + SqlDataType::INTEGER.0, + 10, // column_size + 0, // decimal_digits + &mut id_val as *mut i32 as *mut c_void, + std::mem::size_of::() as isize, + &mut id_ind, + ), + SqlReturn::SUCCESS + ); + + // Bind param 2 (name) as data-at-execution. + // The value_ptr is a token that SQLParamData will return to identify + // which parameter is being requested. + let token: usize = 0xBEEF; + let mut dae_ind: isize = SQL_DATA_AT_EXEC; + assert_eq!( + ffi::params::sql_bind_parameter::( + stmt, + 2, // parameter_number + ParamType::Input as i16, + CDataType::Char as i16, // SQL_C_CHAR + SqlDataType::VARCHAR.0, + 255, // column_size + 0, // decimal_digits + std::ptr::without_provenance_mut(token), // token value + 0, + &mut dae_ind, + ), + SqlReturn::SUCCESS + ); + + // SQLExecute must return NEED_DATA because param 2 is DAE. + assert_eq!( + ffi::execute::sql_execute::(stmt), + SqlReturn::NEED_DATA + ); + + // SQLParamData must return NEED_DATA and write the token for param 2. + let mut value_ptr: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::params::sql_param_data::(stmt, &mut value_ptr), + SqlReturn::NEED_DATA + ); + // The driver should have written back the token we supplied. + assert_eq!(value_ptr as usize, token); + + // SQLPutData: supply the data for the name column. + let data = b"hello"; + assert_eq!( + ffi::params::sql_put_data::( + stmt, + data.as_ptr() as *mut c_void, + data.len() as isize, + ), + SqlReturn::SUCCESS + ); + + // SQLParamData: no more pending params — should execute the INSERT and return SUCCESS. + let mut value_ptr2: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::params::sql_param_data::(stmt, &mut value_ptr2), + SqlReturn::SUCCESS + ); + + // Close cursor from the INSERT before issuing a SELECT on the same handle. + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::SUCCESS + ); + + // Verify the inserted row via ODBC SELECT. + assert_eq!( + exec_direct(stmt, "SELECT name FROM dae_test WHERE id = 42"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let mut name_buf = [0u16; 32]; + let mut name_ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::WChar as i16, + name_buf.as_mut_ptr() as *mut c_void, + (name_buf.len() * 2) as isize, + &mut name_ind, + ), + SqlReturn::SUCCESS + ); + // name_ind is in bytes; divide by 2 to get WChar count. + let char_count = if name_ind > 0 { + (name_ind / 2) as usize + } else { + 0 + }; + let name = String::from_utf16_lossy(&name_buf[..char_count]); + assert_eq!(name, "hello"); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// Column-size round-trip matrix +// +// Coverage: for each representative type below, `SQL_DESC_DISPLAY_SIZE` is +// read via `SQLColAttributeW` and used to size the `SQLGetData(SQL_C_WCHAR)` +// read buffer, so a wrong metadata value (too small) surfaces here as a +// truncated/SUCCESS_WITH_INFO read rather than merely as a wrong number +// somewhere nobody looks. Sizing the read buffer from what the driver itself +// reports is what makes a wrong column-size value in the metadata path fail +// this test. +// +// DISPLAY_SIZE, not OCTET_LENGTH, is the correct field for this: per the +// "Transfer Octet Length" appendix, OCTET_LENGTH is defined as "the maximum +// number of bytes returned ... when data is transferred to its *default* C +// data type": for DATE/TIME/TIMESTAMP that default is the fixed-size +// SQL_DATE_STRUCT/SQL_TIME_STRUCT/SQL_TIMESTAMP_STRUCT (6/6/16 bytes, see +// `col_attr.rs`), and for INTEGER/DOUBLE/etc. it is the native numeric C +// type's byte width (4/8/...); neither has anything to do with the +// character count needed to render the value as text. DISPLAY_SIZE is +// exactly that character count by definition ("the maximum number of +// characters needed to display the data in character form"), so it is what +// an application should (and this test does) use to size a text buffer +// for `SQL_C_WCHAR`/`SQL_C_CHAR`. +// +// Native-C-type round trips (SQL_C_SBIGINT for INTEGER, SQL_C_TYPE_TIMESTAMP +// for TIMESTAMP, etc.) are already covered extensively elsewhere in this +// file (e.g. `get_data_returns_correct_values`, +// `get_data_datetime_column_handles_integer_and_real_storage`) and are not +// duplicated here; those C types have an ODBC-mandated fixed struct size +// regardless of what COLUMN_SIZE/OCTET_LENGTH report, so sizing them "from +// metadata" would not exercise anything metadata-related. +// +// Omitted from this matrix, with reasons: +// - An *undeclared* (unbounded) BLOB/VARBINARY column: its DISPLAY_SIZE is +// i32::MAX * 2 (see `is_binary_type` in col_attr.rs: DISPLAY_SIZE for a +// binary column is its length in bytes times 2, one hex digit pair per +// byte), which is not an allocatable buffer size. The bounded `n_blob +// BLOB(20)` column below exercises the same code path at a size that can +// actually be allocated, which is what an application reading an +// unbounded column is expected to do too (chunked `SQLGetData` calls, +// not one buffer sized from COLUMN_SIZE); this is a property of +// "unbounded" columns in general, not something specific to binary data. +// - TINYINT/SMALLINT/BOOLEAN: fixed-width numeric types whose +// COLUMN_SIZE/OCTET_LENGTH values are simple constants already covered by +// `stackable_odbc_core::types::column_size`'s own unit tests; a live-database round +// trip adds little beyond what INTEGER/DOUBLE below already demonstrate +// for the numeric-type shape. + +/// Read `SQL_DESC_DISPLAY_SIZE` (characters needed to render the value as +/// text; see the module-level comment above for why this, not +/// `OCTET_LENGTH`, is the right field) for one column via `SQLColAttributeW`. +unsafe fn column_display_size(stmt: *mut c_void, column_number: u16) -> usize { + let mut chars: isize = 0; + unsafe { + assert_eq!( + ffi::metadata::sql_col_attribute_w::( + stmt, + column_number, + Desc::DisplaySize as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut chars, + ), + SqlReturn::SUCCESS + ); + } + usize::try_from(chars).expect("DISPLAY_SIZE must not be negative") +} + +/// Fetch column `column_number` as `SQL_C_WCHAR`, using a buffer sized +/// exactly from `SQL_DESC_DISPLAY_SIZE` (plus one UTF-16 code unit of slack +/// for the null terminator, which `DISPLAY_SIZE` does not include per spec). +/// Returns `(SqlReturn, decoded text)`. +unsafe fn get_data_wchar_sized_from_metadata( + stmt: *mut c_void, + column_number: u16, +) -> (SqlReturn, String) { + let chars = unsafe { column_display_size(stmt, column_number) }; + let code_units = chars + 1; // +1 for the null terminator + let mut buf: Vec = vec![0u16; code_units]; + let mut ind: isize = 0; + let ret = unsafe { + ffi::fetch::sql_get_data::( + stmt, + column_number, + CDataType::WChar as i16, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as isize, + &mut ind, + ) + }; + let char_count = if ind > 0 { (ind / 2) as usize } else { 0 }; + ( + ret, + String::from_utf16_lossy(&buf[..char_count.min(buf.len())]), + ) +} + +/// Read the first diagnostic record's 5-character SQLSTATE off `stmt`. +unsafe fn last_sqlstate(stmt: *mut c_void) -> String { + let mut state = [0u16; 6]; + let mut native: i32 = 0; + let mut msg = [0u16; 256]; + let mut msg_len: i16 = 0; + unsafe { + assert_eq!( + ffi::diag::sql_get_diag_rec_w::( + HandleType::Stmt as i16, + stmt, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + msg.len() as i16, + &mut msg_len, + ), + SqlReturn::SUCCESS, + "no diagnostic record was pushed" + ); + } + String::from_utf16_lossy(&state[..5]) +} + +#[test] +fn metadata_sized_wchar_round_trip_covers_representative_types() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE sizing ( + n_int INTEGER, + n_real REAL, + n_text TEXT, + n_date DATE, + n_time TIME, + n_ts TIMESTAMP, + n_dec DECIMAL(10,2), + n_blob BLOB(20), + n_time_frac TIME, + n_ts_frac TIMESTAMP + ); + INSERT INTO sizing VALUES ( + 1234567890, + 3.5, + 'hello world', + '2024-03-05', + '13:30:15', + '2024-03-05 13:30:15', + 123.45, + X'DEADBEEF', + '13:30:15.123', + '2024-03-05 13:30:15.123' + );", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct( + stmt, + "SELECT n_int, n_real, n_text, n_date, n_time, n_ts, n_dec, n_blob, \ + n_time_frac, n_ts_frac FROM sizing" + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + // (column_number, expected text rendering). n_blob is declared + // BLOB(20) (a bounded length; SQLite does not enforce it, but + // parses it the same way as VARCHAR(n)/CHAR(n), see + // `sqlite_declared_type_precision`) specifically so DISPLAY_SIZE is + // a small, allocatable number (40 = 20*2) rather than the + // "unbounded" convention's i32::MAX*2 an undeclared BLOB column + // would report, proving DISPLAY_SIZE reports the hex-text length + // (col_attr.rs's `is_binary_type` branch) end-to-end. + // + // Columns 9/10: SQLite's TIME/TIMESTAMP text is stored and returned + // verbatim (SQLite is dynamically typed text storage, see + // `type_conversion.rs`'s module doc), so these exercise + // `MAX_FRACTIONAL_SECONDS_PRECISION` (3) reporting enough room for a + // fractional value. DISPLAY_SIZE must be 12/23 to hold a fractional + // value; 8/19 (no fractional allowance) would truncate it. + let expectations: &[(u16, &str)] = &[ + (1, "1234567890"), + (2, "3.5"), + (3, "hello world"), + (4, "2024-03-05"), + (5, "13:30:15"), + (6, "2024-03-05 13:30:15"), + (7, "123.45"), + (8, "DEADBEEF"), + (9, "13:30:15.123"), + (10, "2024-03-05 13:30:15.123"), + ]; + + for &(col, expected) in expectations { + let (ret, text) = get_data_wchar_sized_from_metadata(stmt, col); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "column {col}: OCTET_LENGTH-sized buffer was not big enough \ + (metadata under-reported the size, or SUCCESS_WITH_INFO/ERROR \ + was otherwise returned)" + ); + assert_eq!(text, expected, "column {col}: unexpected text rendering"); + } + + cleanup(env, conn, stmt); + } +} + +// --- Cross-family conversions --- +// +// These three shapes cover the class of bug where a value is converted across +// a type-family boundary, or a column whose metadata says one thing while its +// actual stored representation is another. + +/// A numeric column read as a cross-family C type +/// (`SQL_C_DOUBLE`). For SQLite, a `DECIMAL`-declared column's actual +/// storage is NUMERIC-affinity `REAL` (SQLite has no distinct DECIMAL +/// storage class), so this exercises `write_column_value`'s numeric-pivot +/// arm end-to-end; the equivalent *text*-sourced pivot (a value that +/// literally arrives as `ColumnValue::String`/`ColumnValue::Decimal` parsed +/// through `parse_numeric_text`) is what +/// `numeric_looking_text_column_read_as_sbigint_below` exercises here for +/// SQLite's own TEXT-affinity columns. +#[test] +fn decimal_column_read_as_double() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + assert_eq!( + exec_direct(stmt, "SELECT CAST(123.45 AS DECIMAL(10,2)) AS amount"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let mut buf: f64 = 0.0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::Double as i16, + &mut buf as *mut f64 as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert!((buf - 123.45).abs() < 1e-9, "got {buf}"); + + cleanup(env, conn, stmt); + } +} + +/// A TEXT-affinity column holding digit text, read as `SQL_C_SBIGINT`, must +/// succeed (the ODBC conversion matrix requires CHAR/VARCHAR to convert to +/// every C type); the same column holding non-numeric text must fail with +/// the specific SQLSTATE the spec defines for it (22018, "invalid character +/// value for cast"), not merely "some error"; asserting only the latter is +/// exactly what would have let a wrong-but-still-erroring conversion slip +/// through on this branch. +#[test] +fn numeric_looking_text_column_read_as_sbigint() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // Success case: digit text parses cleanly. + assert_eq!(exec_direct(stmt, "SELECT '12345'"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + let mut buf: i64 = 0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut buf as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(buf, 12345); + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::SUCCESS + ); + + // Failure case: non-numeric text must report 22018, not just "an error". + assert_eq!( + exec_direct(stmt, "SELECT 'not a number'"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + let mut buf2: i64 = 0; + let mut ind2: isize = 0; + let ret2 = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::SBigInt as i16, + &mut buf2 as *mut i64 as *mut c_void, + std::mem::size_of::() as isize, + &mut ind2, + ); + assert_eq!(ret2, SqlReturn::ERROR); + assert_eq!(last_sqlstate(stmt), "22018"); + + cleanup(env, conn, stmt); + } +} + +/// A TIMESTAMP-declared column whose value actually arrived as SQLite TEXT +/// (the third of SQLite's three documented DATETIME storage formats; the +/// other two, INTEGER epoch seconds and REAL Julian day, are already covered +/// by `get_data_datetime_column_handles_integer_and_real_storage` above), +/// read as `SQL_C_TYPE_TIMESTAMP`. Well-formed text must round-trip exactly; +/// malformed text must report the specific SQLSTATE the spec defines +/// (22018, "invalid character value for cast", scoped to a character +/// column source per the `SQLGetData` diagnostics table), not merely fail. +#[test] +fn timestamp_column_stored_as_text_read_as_type_timestamp() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let conn_handle = stackable_odbc_core::handles::as_handle_ref::< + stackable_odbc_core::handles::ConnectionHandle, + >(conn) + .expect("valid conn"); + { + let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); + let db = sqlite_conn.conn.lock().expect("lock"); + db.execute_batch( + "CREATE TABLE ts_text (id INTEGER, dt TIMESTAMP); \ + INSERT INTO ts_text VALUES (1, '2024-03-05 13:30:15'); \ + INSERT INTO ts_text VALUES (2, 'not-a-timestamp');", + ) + .expect("setup"); + } + + assert_eq!( + exec_direct(stmt, "SELECT dt FROM ts_text ORDER BY id"), + SqlReturn::SUCCESS + ); + + // Row 1: well-formed text round-trips exactly. + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + let mut buf = RawTimestamp { + year: 0, + month: 0, + day: 0, + hour: 0, + minute: 0, + second: 0, + fraction: 0, + }; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::TypeTimestamp as i16, + &mut buf as *mut RawTimestamp as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "text-encoded datetime"); + assert_eq!((buf.year, buf.month, buf.day), (2024, 3, 5)); + assert_eq!((buf.hour, buf.minute, buf.second), (13, 30, 15)); + + // Row 2: malformed text must report 22018, not just "an error". + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + let mut buf2 = buf; + let mut ind2: isize = 0; + let ret2 = ffi::fetch::sql_get_data::( + stmt, + 1, + CDataType::TypeTimestamp as i16, + &mut buf2 as *mut RawTimestamp as *mut c_void, + std::mem::size_of::() as isize, + &mut ind2, + ); + assert_eq!(ret2, SqlReturn::ERROR); + assert_eq!(last_sqlstate(stmt), "22018"); + + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLGetInfoW info-type conformance test +// --------------------------------------------------------------------------- +// +// Three real, shipped bugs were all "nothing enumerated the spec": a value +// the Windows Driver Manager treats as an integer where the driver returned +// a string (or vice versa), and conversion bitmaps that returned 0 (which +// makes the Windows DM block SQLGetData with HYC00). Line coverage stayed +// green throughout, because the code path that produced the wrong answer +// ran constantly -- nobody had asserted what it returned for every info +// type, just the ones a test happened to name. +// +// These two tests close that gap by iterating every `InfoType` odbc-sys +// compiles (derived from `info_type_from_raw`, not a hand-copied list -- see +// `stackable_odbc_core::conformance`) through the real `sql_get_info_w` FFI entry +// point, against the real `SqliteBackend`, connected and pre-connect. + +/// Property 1: every `InfoType`'s returned value has the shape the +/// SQLGetInfo spec declares for it (`stackable_odbc_core::types::expected_kind`), +/// whether `SqliteBackend` answers it itself (`sqlite_get_info`), falls +/// through to the shared `default_get_info`, or reaches the generic +/// DM-safe default in `info_type_default_response`. All three layers are +/// exercised here because this goes through the real FFI entry point rather +/// than calling any one of them directly. +#[test] +fn get_info_every_named_info_type_has_the_declared_shape_connected() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + for info_type in all_info_types() { + let (ret, kind, _string_length) = + observe_info_value_kind::(conn, info_type as u16); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "{info_type:?}: SQLGetInfoW must not return SQL_ERROR" + ); + assert_eq!( + kind, + expected_kind(info_type), + "{info_type:?}: SqliteBackend returned shape {kind:?}, expected \ + {:?} per the SQLGetInfo spec", + expected_kind(info_type) + ); + } + + cleanup(env, conn, stmt); + } +} + +/// Property 1, pre-connect path: the Windows Driver Manager queries some +/// info types (e.g. `SQL_DRIVER_ODBC_VER`) before `SQLDriverConnectW`, which +/// routes through `SqliteBackend::get_info_pre_connect` instead of +/// `get_info`. `sqlite_get_info` backs both, so this is expected to match +/// the connected test above for every info type -- asserted separately +/// because the two call sites in `sql_get_info_w` are independent code +/// paths that could regress independently. +#[test] +fn get_info_every_named_info_type_has_the_declared_shape_pre_connect() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + + for info_type in all_info_types() { + let (ret, kind, _string_length) = + observe_info_value_kind::(conn, info_type as u16); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "{info_type:?}: SQLGetInfoW must not return SQL_ERROR pre-connect" + ); + assert_eq!( + kind, + expected_kind(info_type), + "{info_type:?}: SqliteBackend returned shape {kind:?} pre-connect, \ + expected {:?} per the SQLGetInfo spec", + expected_kind(info_type) + ); + } + + cleanup(env, conn, stmt); + } +} + +/// Property 2: no genuine `SQL_CONVERT_*` code ever returns 0 through +/// `SqliteBackend` -- per `AGENTS.md`, a `0` conversion bitmap is what makes +/// the Windows Driver Manager block `SQLGetData` with `HYC00`. +#[test] +fn get_info_no_genuine_convert_info_type_ever_returns_zero() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + for info_type in genuine_convert_info_types() { + let (ret, value) = observe_u32_value::(conn, info_type); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "raw SQL_CONVERT_* info type {info_type} must not error" + ); + assert_ne!( + value, 0, + "raw SQL_CONVERT_* info type {info_type} returned 0 -- this is the \ + exact shape that makes the Windows Driver Manager block SQLGetData \ + with HYC00 (AGENTS.md)" + ); + } + + cleanup(env, conn, stmt); + } +} + +/// End-to-end proof that `SqliteBackend::escape_dialect()` is actually wired +/// into the execute path: `SQLExecDirect` is given raw ODBC escape syntax +/// (`{fn UCASE(...)}`) that is not a real SQLite function name on its own, +/// and only succeeds because `sql_exec_direct_w` translates it first (see +/// `crate::escape_dialect`). Runs against an in-memory database, no server. +#[test] +fn escape_fn_ucase_translates_for_sqlite() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + exec_direct(stmt, "SELECT {fn UCASE('abc')}"), + SqlReturn::SUCCESS, + "exec_direct with {{fn UCASE}} escape failed to translate" + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + assert_eq!( + fetch_string_col(stmt, 1), + "ABC", + "{{fn UCASE(...)}} not remapped to upper()" + ); + + cleanup(env, conn, stmt); + } +} + +/// A `{ts '...'}` timestamp escape must render to a bare string literal +/// SQLite accepts, not `TIMESTAMP '...'` (SQLite has no such type keyword and +/// would reject it as a syntax error). +#[test] +fn escape_ts_literal_renders_as_bare_string_for_sqlite() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + exec_direct( + stmt, + "SELECT {ts '2020-01-01 00:00:00'} WHERE {ts '2020-01-01 00:00:00'} = '2020-01-01 00:00:00'" + ), + SqlReturn::SUCCESS, + "exec_direct with {{ts '...'}} escape failed to translate" + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + assert_eq!( + fetch_string_col(stmt, 1), + "2020-01-01 00:00:00", + "{{ts '...'}} was not rendered as a bare string literal" + ); + + cleanup(env, conn, stmt); + } +} + +/// `{fn CURDATE()}` must be remapped to SQLite's zero-argument `date()` and +/// actually execute against the database, not just get rewritten as text. +/// The exact date is time-dependent, so this only checks the ISO +/// `YYYY-MM-DD` shape (length 10, dashes at the positions the format +/// mandates) and that a value came back at all. +#[test] +fn escape_fn_curdate_executes_as_sqlite_date() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + exec_direct(stmt, "SELECT {fn CURDATE()}"), + SqlReturn::SUCCESS, + "exec_direct with {{fn CURDATE()}} escape failed to translate" + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let curdate = fetch_string_col(stmt, 1); + const ISO_DATE_LEN: usize = "YYYY-MM-DD".len(); + assert_eq!( + curdate.len(), + ISO_DATE_LEN, + "{{fn CURDATE()}} did not return a YYYY-MM-DD date string, got {curdate:?}" + ); + assert_eq!( + curdate.as_bytes()[4], + b'-', + "{{fn CURDATE()}} result missing '-' separator after year: {curdate:?}" + ); + assert_eq!( + curdate.as_bytes()[7], + b'-', + "{{fn CURDATE()}} result missing '-' separator after month: {curdate:?}" + ); + + cleanup(env, conn, stmt); + } +} + +/// `{fn NOW()}` must be remapped to SQLite's zero-argument `datetime()` and +/// actually execute against the database. As with CURDATE, only the ISO +/// `YYYY-MM-DD HH:MM:SS` shape is checked (length 19, dashes/colons/space at +/// their mandated positions); the exact timestamp is time-dependent. +#[test] +fn escape_fn_now_executes_as_sqlite_datetime() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + exec_direct(stmt, "SELECT {fn NOW()}"), + SqlReturn::SUCCESS, + "exec_direct with {{fn NOW()}} escape failed to translate" + ); + assert_eq!( + ffi::fetch::sql_fetch::(stmt), + SqlReturn::SUCCESS + ); + + let now = fetch_string_col(stmt, 1); + const ISO_DATETIME_LEN: usize = "YYYY-MM-DD HH:MM:SS".len(); + assert_eq!( + now.len(), + ISO_DATETIME_LEN, + "{{fn NOW()}} did not return a YYYY-MM-DD HH:MM:SS datetime string, got {now:?}" + ); + assert_eq!( + now.as_bytes()[4], + b'-', + "{{fn NOW()}} result missing '-' separator after year: {now:?}" + ); + assert_eq!( + now.as_bytes()[7], + b'-', + "{{fn NOW()}} result missing '-' separator after month: {now:?}" + ); + assert_eq!( + now.as_bytes()[10], + b' ', + "{{fn NOW()}} result missing space between date and time: {now:?}" + ); + assert_eq!( + now.as_bytes()[13], + b':', + "{{fn NOW()}} result missing ':' separator after hour: {now:?}" + ); + assert_eq!( + now.as_bytes()[16], + b':', + "{{fn NOW()}} result missing ':' separator after minute: {now:?}" + ); + + cleanup(env, conn, stmt); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ec20194 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,26 @@ +//! ODBC driver for [SQLite](https://sqlite.org), built on the generic +//! [`stackable_odbc_core`] framework. +//! +//! This crate compiles to a C dynamic library (`cdylib`) that an ODBC Driver +//! Manager (unixODBC on Linux, the built-in DM on Windows) loads at runtime — +//! it is not used as a normal Rust dependency. All the ODBC C ABI entry points +//! are generated by [`stackable_odbc_core::forward_ffi!`] from the [`SqliteBackend`] +//! implementation; the Driver Manager translates ANSI calls, so only the +//! Unicode (`W`) functions are exported. +//! +//! The backend opens a local SQLite database file through `rusqlite` (with the +//! bundled SQLite library). The database path is taken from the `Database` +//! connection-string key; `:memory:` opens an in-memory database. Because +//! SQLite is dynamically typed, column SQL types are inferred and its five +//! storage classes are mapped onto ODBC types. + +mod backend; +mod escape_dialect; +mod type_conversion; + +pub use backend::SqliteBackend; + +stackable_odbc_core::forward_ffi!(crate::backend::SqliteBackend); + +#[cfg(test)] +mod ffi_integration_tests; diff --git a/src/type_conversion.rs b/src/type_conversion.rs new file mode 100644 index 0000000..b780d4a --- /dev/null +++ b/src/type_conversion.rs @@ -0,0 +1,1017 @@ +//! Conversion between SQLite's `rusqlite::types::Value` and `stackable-odbc-core`'s +//! [`ColumnValue`], including SQLite's numeric datetime storage encodings and +//! the declared fractional-seconds precision this driver reports. + +use rusqlite::types::Value; +use stackable_odbc_core::types::{ColumnValue, SqlDataType, column_size}; + +/// This driver's declared maximum fractional-seconds precision for +/// TIME/TIMESTAMP columns. SQLite has no native temporal type system at all: +/// `TIME`/`TIMESTAMP` values are plain `TEXT` (or the numeric encodings +/// decoded below) with no declared scale in the schema, so there is no +/// larger, separately-declarable server maximum to report (as there would be +/// for a backend with a `time(N)`/`timestamp(N)` schema type): "the maximum +/// this data source supports" and "what a column actually delivers" are the +/// same number by construction here. +/// +/// The number itself comes from SQLite's own documented datetime format. +/// SQLite's date-and-time-functions page +/// () lists +/// `YYYY-MM-DD HH:MM:SS.SSS` (format 4/7) as one of the ISO-8601 formats it +/// both accepts and produces, and documents `strftime`'s `%f` substitution +/// as "fractional seconds: SS.SSS" (three digits), the format SQLite's own +/// date/time functions (e.g. `datetime('now')`) render by default. That +/// makes 3 the honest "this is what a SQLite TIME/TIMESTAMP column +/// ordinarily looks like" figure for a driver with no schema-level scale to +/// read a tighter or looser bound from, matching what +/// `column_value_to_rusqlite`'s `Time`/`Timestamp` arms below produce for +/// this driver's own bound parameters. +/// +/// A value with a genuinely finer fraction than 3 digits (e.g. hand-written +/// as `'...:15.123456'`, or read back through +/// `column_value_to_rusqlite`'s current 9-digit-nanosecond rendering) still +/// round-trips as data: SQLite text storage is unbounded, so the extra +/// digits are neither rejected nor truncated in storage, only +/// under-reported by `SQL_DESC_DISPLAY_SIZE`/`COLUMN_SIZE` -- the same kind +/// of "declared vs. actual" gap every other undeclared-length default in +/// `default_precision_for_type` already carries, for the same reason (no +/// real schema constraint to consult). That is an accepted, general +/// limitation of describing a dynamically typed column ahead of fetching +/// it, not something this specific constant introduces. +pub(crate) const MAX_FRACTIONAL_SECONDS_PRECISION: i16 = 3; + +// --------------------------------------------------------------------------- +// SQLite's numeric datetime storage encodings +// --------------------------------------------------------------------------- +// +// SQLite is dynamically typed and has no dedicated DATE/TIME/DATETIME storage +// class: a column declared as one of those types (and reported to the +// application as `SQL_TYPE_DATE` / `SQL_TYPE_TIME` / `SQL_TYPE_TIMESTAMP` via +// [`sqlite_type_to_sql_data_type`]) may still physically hold any of the three +// formats SQLite's own date/time functions document and produce: ISO-8601 +// text, an `INTEGER` count of seconds since the Unix epoch, or a `REAL` +// Julian day number (days since noon, proleptic Gregorian -4713-11-24). Text +// is already handled generically by `stackable-odbc-core` (`ColumnValue::String` converts +// to any C datetime type per the ODBC conversion matrix); the two numeric +// encodings are a SQLite-specific convention, so they are decoded here, at +// fetch time, where the column's declared type is known -- `stackable-odbc-core` must +// not carry this backend-specific knowledge (see its `write_column_value` +// doc comment). + +/// Convert a [`ColumnValue`] (from ODBC parameter binding) to a [`rusqlite::types::Value`] +/// so it can be passed to `params_from_iter` in parameterized queries. +/// +/// Date/Time/Timestamp values are formatted as ISO-8601 strings, which SQLite +/// stores and compares correctly via its built-in date functions. +/// GUID values are formatted as the standard hyphenated hex string. +pub fn column_value_to_rusqlite(value: &ColumnValue) -> Value { + match value { + ColumnValue::Null => Value::Null, + ColumnValue::String(s) => Value::Text(s.clone()), + ColumnValue::I8(i) => Value::Integer(*i as i64), + ColumnValue::I16(i) => Value::Integer(*i as i64), + ColumnValue::I32(i) => Value::Integer(*i as i64), + ColumnValue::I64(i) => Value::Integer(*i), + ColumnValue::F32(f) => Value::Real(*f as f64), + ColumnValue::F64(f) => Value::Real(*f), + ColumnValue::Bool(b) => Value::Integer(*b as i64), + ColumnValue::Bytes(b) => Value::Blob(b.clone()), + ColumnValue::Date { year, month, day } => { + Value::Text(format!("{year:04}-{month:02}-{day:02}")) + } + ColumnValue::Time { + hour, + minute, + second, + fraction, + } => Value::Text(format!("{hour:02}:{minute:02}:{second:02}.{fraction:09}")), + ColumnValue::Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + } => Value::Text(format!( + "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{fraction:09}" + )), + ColumnValue::Guid(bytes) => { + let b = bytes; + Value::Text(format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + b[0], + b[1], + b[2], + b[3], + b[4], + b[5], + b[6], + b[7], + b[8], + b[9], + b[10], + b[11], + b[12], + b[13], + b[14], + b[15] + )) + } + // DECIMAL/NUMERIC has no native SQLite storage class; keep the exact + // decimal text so precision is never lost (SQLite compares numeric + // text correctly in arithmetic contexts). + ColumnValue::Decimal(s) => Value::Text(s.clone()), + // New ColumnValue variants are not natively representable in SQLite. + // TODO(spec): HYC00 — optional feature not implemented; cannot store complex types in SQLite. + _ => { + tracing::warn!( + value = ?value, + "column_value_to_rusqlite: unhandled ColumnValue variant, storing as empty text" + ); + Value::Text(String::new()) + } + } +} + +/// Convert a [`rusqlite::types::Value`] (from a query result row) to a [`ColumnValue`], +/// given the column's declared ODBC SQL type (as computed by +/// [`sqlite_type_to_sql_data_type`] from the same `decl_type` string). +/// +/// Takes `Value` by ownership so that `Text` / `Blob` can move their heap +/// buffers directly into `ColumnValue::String` / `ColumnValue::Bytes` without +/// cloning: rusqlite gives us a freshly-owned `Value` per cell, so a clone +/// here would be pure waste. +/// +/// A column declared `DATE`/`TIME`/`DATETIME`/`TIMESTAMP` is described to the +/// application as the corresponding ODBC datetime SQL type, but SQLite may +/// still have stored the value as `INTEGER` (epoch seconds) or `REAL` (Julian +/// day) rather than text -- see the module-level doc comment above. Those two +/// cases are decoded here into a proper `ColumnValue::Date`/`Time`/`Timestamp` +/// so `stackable-odbc-core`, which holds no SQLite-specific knowledge, only ever sees a +/// correctly typed value. +pub fn sqlite_value_to_column_value(value: Value, sql_type: SqlDataType) -> ColumnValue { + match (value, sql_type) { + ( + Value::Integer(epoch_seconds), + SqlDataType::DATE | SqlDataType::TIME | SqlDataType::TIMESTAMP, + ) => decode_epoch_seconds(epoch_seconds, sql_type).unwrap_or_else(|| { + tracing::warn!( + epoch_seconds, + ?sql_type, + "sqlite_value_to_column_value: epoch-seconds value does not decode to a \ + representable datetime (year out of i16 range); returning the raw integer" + ); + ColumnValue::I64(epoch_seconds) + }), + ( + Value::Real(julian_day), + SqlDataType::DATE | SqlDataType::TIME | SqlDataType::TIMESTAMP, + ) => decode_julian_day(julian_day, sql_type).unwrap_or_else(|| { + tracing::warn!( + julian_day, + ?sql_type, + "sqlite_value_to_column_value: Julian day value does not decode to a \ + representable datetime (non-finite, or year out of i16 range); returning the \ + raw float" + ); + ColumnValue::F64(julian_day) + }), + (Value::Null, _) => ColumnValue::Null, + (Value::Integer(i), _) => ColumnValue::I64(i), + (Value::Real(f), _) => ColumnValue::F64(f), + (Value::Text(s), _) => ColumnValue::String(s), + (Value::Blob(b), _) => ColumnValue::Bytes(b), + } +} + +/// A decoded (year, month, day, hour, minute, second, nanosecond) civil +/// timestamp, before it is narrowed to whichever of `ColumnValue::Date` / +/// `Time` / `Timestamp` the column's declared SQL type calls for. +struct DecodedDateTime { + year: i16, + month: u16, + day: u16, + hour: u16, + minute: u16, + second: u16, + fraction: u32, +} + +impl DecodedDateTime { + /// Narrow to whichever `ColumnValue` variant `sql_type` calls for. + /// + /// `sql_type` is always one of `DATE`/`TIME`/`TIMESTAMP` here -- the only + /// values the caller matches on before reaching this point -- so the + /// fallback arm is unreachable in practice; it maps to `Timestamp` rather + /// than panicking, since `SqlDataType` is not our enum to exhaustively + /// match without a wildcard. + fn into_column_value(self, sql_type: SqlDataType) -> ColumnValue { + match sql_type { + SqlDataType::DATE => ColumnValue::Date { + year: self.year, + month: self.month, + day: self.day, + }, + SqlDataType::TIME => ColumnValue::Time { + hour: self.hour, + minute: self.minute, + second: self.second, + // `decode_epoch_seconds` always passes 0 nanos (an INTEGER + // epoch-seconds value has no sub-second part), but + // `decode_julian_day`'s REAL encoding can carry a genuine + // fraction -- `self.fraction` is real data, not a placeholder. + fraction: self.fraction, + }, + _ => ColumnValue::Timestamp { + year: self.year, + month: self.month, + day: self.day, + hour: self.hour, + minute: self.minute, + second: self.second, + fraction: self.fraction, + }, + } + } +} + +/// Decompose a day count since the Unix epoch (1970-01-01) into a proleptic +/// Gregorian (year, month, day). +/// +/// This is the well-known "civil_from_days" algorithm (Howard Hinnant, +/// public domain: ), +/// valid for the entire range of `i64` day counts. All intermediate +/// arithmetic is carried out in `i128` so it cannot overflow regardless of +/// the input magnitude; the caller is responsible for range-checking the +/// resulting year against the target field width. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = i128::from(days) + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u128; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i128 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + let y = if m <= 2 { y + 1 } else { y }; + (y as i64, m, d) +} + +/// Decode a count of seconds since the Unix epoch, plus a nanosecond fraction +/// already isolated by the caller, into a [`DecodedDateTime`]. +/// +/// Returns `None` if the resulting year does not fit `SQL_TIMESTAMP_STRUCT.year` +/// (`i16`) -- see [`decode_epoch_seconds`] for how callers handle that. +fn timestamp_from_epoch_seconds(total_seconds: i64, nanos: u32) -> Option { + let days = total_seconds.div_euclid(86_400); + let secs_of_day = total_seconds.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + let year = i16::try_from(year).ok()?; + let hour = (secs_of_day / 3600) as u16; + let minute = ((secs_of_day % 3600) / 60) as u16; + let second = (secs_of_day % 60) as u16; + Some(DecodedDateTime { + year, + month: month as u16, + day: day as u16, + hour, + minute, + second, + fraction: nanos, + }) +} + +/// Decode a SQLite `INTEGER` datetime column (epoch seconds) into the +/// [`ColumnValue`] variant `sql_type` (`DATE`/`TIME`/`TIMESTAMP`) calls for. +/// +/// Returns `None` if the decoded year does not fit `SQL_TIMESTAMP_STRUCT.year` +/// (`i16`); the caller falls back to the raw `ColumnValue::I64` in that case +/// (see [`sqlite_value_to_column_value`]'s doc comment) rather than failing +/// the fetch outright. +fn decode_epoch_seconds(epoch_seconds: i64, sql_type: SqlDataType) -> Option { + let decoded = timestamp_from_epoch_seconds(epoch_seconds, 0)?; + Some(decoded.into_column_value(sql_type)) +} + +/// Decode a SQLite `REAL` datetime column (Julian day number, days since noon +/// on proleptic-Gregorian -4713-11-24 -- the convention SQLite's own +/// `julianday()` function uses) into the [`ColumnValue`] variant `sql_type` +/// calls for. +/// +/// Julian day 2440587.5 is the Unix epoch, so the value is first rebased to +/// epoch seconds (with a fractional part) and then decoded the same way as +/// [`decode_epoch_seconds`]. Returns `None` for non-finite input or a value +/// whose implied year does not fit `i16`; the caller falls back to the raw +/// `ColumnValue::F64` in that case. +fn decode_julian_day(julian_day: f64, sql_type: SqlDataType) -> Option { + const JULIAN_DAY_UNIX_EPOCH: f64 = 2_440_587.5; + if !julian_day.is_finite() { + return None; + } + let unix_seconds = (julian_day - JULIAN_DAY_UNIX_EPOCH) * 86_400.0; + // i64 as f64 is inexact at the extremes, but comparing against the exact + // bounds is enough to reject anything that would saturate on cast below. + if !unix_seconds.is_finite() || unix_seconds < i64::MIN as f64 || unix_seconds > i64::MAX as f64 + { + return None; + } + let whole_seconds = unix_seconds.floor(); + let frac_seconds = unix_seconds - whole_seconds; + let nanos = (frac_seconds * 1_000_000_000.0) + .round() + .clamp(0.0, 999_999_999.0) as u32; + let decoded = timestamp_from_epoch_seconds(whole_seconds as i64, nanos)?; + Some(decoded.into_column_value(sql_type)) +} + +/// Split a declared type into its base name and parenthesised arguments. +/// +/// `"DECIMAL(10,2)"` → `("DECIMAL", ["10", "2"])`. SQLite stores the declared +/// type verbatim, so `VARCHAR(50)` arrives with its length attached. +fn split_declared_type(decl_type: &str) -> (String, Vec) { + let t = decl_type.trim(); + match t.split_once('(') { + Some((base, rest)) => { + let args = rest + .trim_end() + .strip_suffix(')') + .unwrap_or(rest) + .split(',') + .map(|a| a.trim().to_string()) + .collect(); + (base.trim().to_uppercase(), args) + } + None => (t.to_uppercase(), Vec::new()), + } +} + +/// SQLite's column affinity algorithm. +/// +/// +/// The five rules are applied in order as substring matches; the first hit +/// wins. Used for declared types we do not recognise explicitly, so that an +/// unknown type is still described sensibly rather than as VARCHAR. +/// +/// Its only possible outputs (`EXT_BIG_INT`, `EXT_W_VARCHAR`, +/// `EXT_VAR_BINARY`, `DOUBLE`, `DECIMAL`) are all already reachable through +/// [`SQLITE_DECLARED_TYPE_ALIASES`] too, so no separate `SQLGetTypeInfo` +/// completeness coverage is needed for this fallback path specifically (see +/// `every_reportable_type_has_a_type_info_row` in `backend/info.rs`). +fn sqlite_affinity(upper: &str) -> SqlDataType { + if upper.contains("INT") { + SqlDataType::EXT_BIG_INT + } else if upper.contains("CHAR") || upper.contains("CLOB") || upper.contains("TEXT") { + SqlDataType::EXT_W_VARCHAR + } else if upper.contains("BLOB") || upper.is_empty() { + SqlDataType::EXT_VAR_BINARY + } else if upper.contains("REAL") || upper.contains("FLOA") || upper.contains("DOUB") { + SqlDataType::DOUBLE + } else { + SqlDataType::DECIMAL + } +} + +/// Every declared-type spelling this driver recognises explicitly, paired +/// with the `SqlDataType` it maps to. +/// +/// [`sqlite_type_to_sql_data_type`] looks this table up directly instead of +/// a `match` with the same spellings transcribed a second time, so a +/// completeness test (`every_reportable_type_has_a_type_info_row` in +/// `backend/info.rs`) can iterate this table itself (the same data the +/// mapping uses) rather than a hand-copied list that can silently omit a +/// spelling added here later. Anything not in this table falls back to +/// [`sqlite_affinity`] (see its doc comment for why that needs no separate +/// coverage). +pub(crate) const SQLITE_DECLARED_TYPE_ALIASES: &[(&str, SqlDataType)] = &[ + ("INTEGER", SqlDataType::EXT_BIG_INT), + ("INT", SqlDataType::EXT_BIG_INT), + ("BIGINT", SqlDataType::EXT_BIG_INT), + ("INT8", SqlDataType::EXT_BIG_INT), + ("SMALLINT", SqlDataType::SMALLINT), + ("INT2", SqlDataType::SMALLINT), + ("TINYINT", SqlDataType::EXT_TINY_INT), + ("REAL", SqlDataType::DOUBLE), + ("DOUBLE", SqlDataType::DOUBLE), + ("DOUBLE PRECISION", SqlDataType::DOUBLE), + ("FLOAT", SqlDataType::DOUBLE), + ("BOOLEAN", SqlDataType::EXT_BIT), + ("BOOL", SqlDataType::EXT_BIT), + ("BLOB", SqlDataType::EXT_VAR_BINARY), + ("DECIMAL", SqlDataType::DECIMAL), + ("NUMERIC", SqlDataType::DECIMAL), + ("DATE", SqlDataType::DATE), + ("TIME", SqlDataType::TIME), + ("DATETIME", SqlDataType::TIMESTAMP), + ("TIMESTAMP", SqlDataType::TIMESTAMP), + ("VARCHAR", SqlDataType::EXT_W_VARCHAR), + ("CHAR", SqlDataType::EXT_W_VARCHAR), + ("CHARACTER", SqlDataType::EXT_W_VARCHAR), + ("NCHAR", SqlDataType::EXT_W_VARCHAR), + ("NVARCHAR", SqlDataType::EXT_W_VARCHAR), + ("VARYING CHARACTER", SqlDataType::EXT_W_VARCHAR), + ("NATIVE CHARACTER", SqlDataType::EXT_W_VARCHAR), + ("TEXT", SqlDataType::EXT_W_VARCHAR), + ("CLOB", SqlDataType::EXT_W_VARCHAR), +]; + +/// Map a SQLite declared column type to an ODBC `SqlDataType`. +/// +/// SQLite does not constrain declared types, so this recognises the common SQL +/// spellings explicitly (via [`SQLITE_DECLARED_TYPE_ALIASES`]) and falls back +/// to SQLite's own affinity rules ([`sqlite_affinity`]) for anything else. +pub fn sqlite_type_to_sql_data_type(decl_type: &str) -> SqlDataType { + let (base, _) = split_declared_type(decl_type); + SQLITE_DECLARED_TYPE_ALIASES + .iter() + .find(|(name, _)| *name == base.as_str()) + .map(|(_, ty)| *ty) + .unwrap_or_else(|| sqlite_affinity(&base)) +} + +/// Column size for a declared type, using the declared length where present. +pub fn sqlite_declared_type_precision(decl_type: &str) -> u32 { + let (_, args) = split_declared_type(decl_type); + if let Some(first) = args.first() + && let Ok(n) = first.parse::() + { + return n; + } + default_precision_for_type(sqlite_type_to_sql_data_type(decl_type)) +} + +/// Decimal digits for a declared type, from the second parenthesised argument. +pub fn sqlite_declared_type_scale(decl_type: &str) -> i16 { + let (_, args) = split_declared_type(decl_type); + args.get(1).and_then(|s| s.parse::().ok()).unwrap_or(0) +} + +// Backend policy constants used as the `precision`/`max_precision` input to +// `column_size`/`catalog_column_size` below. Unlike the fixed-size integer +// and float types (whose column size the ODBC appendix defines as a +// constant regardless of what precision is supplied), these three represent +// an actual choice this driver makes for an *undeclared* column of the type; +// they are not themselves derived from the appendix. +pub(crate) const VARCHAR_DEFAULT_COLUMN_SIZE: i32 = 255; // SQLite default text column size +pub(crate) const DECIMAL_DEFAULT_COLUMN_SIZE: i32 = 38; // conventional max precision for undeclared DECIMAL/NUMERIC +pub(crate) const BLOB_DEFAULT_COLUMN_SIZE: i32 = i32::MAX; // matches the BLOB row in backend/info.rs + +/// Narrow a [`column_size`] result (`i32`, always non-negative for every +/// type this driver reports) to the `u32` this function has always +/// returned. Defensive: none of the arms below can actually produce a +/// negative value, but the fallback keeps this panic-free rather than +/// relying on that invariant silently. +fn precision_as_u32(n: i32) -> u32 { + u32::try_from(n).unwrap_or_else(|_| { + tracing::warn!( + value = n, + "column size formula produced a value outside u32 range" + ); + 0 + }) +} + +/// Return a reasonable default precision for a given SQL data type. +pub fn default_precision_for_type(sql_type: SqlDataType) -> u32 { + match sql_type { + SqlDataType::EXT_BIG_INT => precision_as_u32(column_size(SqlDataType::EXT_BIG_INT, 0, 0)), + SqlDataType::SMALLINT => precision_as_u32(column_size(SqlDataType::SMALLINT, 0, 0)), + SqlDataType::EXT_TINY_INT => precision_as_u32(column_size(SqlDataType::EXT_TINY_INT, 0, 0)), + SqlDataType::DOUBLE => precision_as_u32(column_size(SqlDataType::DOUBLE, 0, 0)), + SqlDataType::EXT_BIT => precision_as_u32(column_size(SqlDataType::EXT_BIT, 0, 0)), + SqlDataType::DECIMAL => precision_as_u32(column_size( + SqlDataType::DECIMAL, + DECIMAL_DEFAULT_COLUMN_SIZE, + 0, + )), + SqlDataType::DATE => precision_as_u32(column_size(SqlDataType::DATE, 0, 0)), + SqlDataType::TIME => precision_as_u32(column_size( + SqlDataType::TIME, + 0, + MAX_FRACTIONAL_SECONDS_PRECISION, + )), + SqlDataType::TIMESTAMP => precision_as_u32(column_size( + SqlDataType::TIMESTAMP, + 0, + MAX_FRACTIONAL_SECONDS_PRECISION, + )), + SqlDataType::EXT_VAR_BINARY => precision_as_u32(column_size( + SqlDataType::EXT_VAR_BINARY, + BLOB_DEFAULT_COLUMN_SIZE, + 0, + )), + SqlDataType::EXT_W_VARCHAR | SqlDataType::VARCHAR => { + precision_as_u32(column_size(sql_type, VARCHAR_DEFAULT_COLUMN_SIZE, 0)) + } + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn convert_integer() { + assert_eq!( + sqlite_value_to_column_value(Value::Integer(42), SqlDataType::EXT_BIG_INT), + ColumnValue::I64(42) + ); + } + + #[test] + fn convert_real() { + assert_eq!( + sqlite_value_to_column_value(Value::Real(std::f64::consts::PI), SqlDataType::DOUBLE), + ColumnValue::F64(std::f64::consts::PI) + ); + } + + #[test] + fn convert_text() { + assert_eq!( + sqlite_value_to_column_value(Value::Text("hi".into()), SqlDataType::EXT_W_VARCHAR), + ColumnValue::String("hi".into()) + ); + } + + #[test] + fn convert_blob() { + assert_eq!( + sqlite_value_to_column_value(Value::Blob(vec![1, 2, 3]), SqlDataType::EXT_VAR_BINARY), + ColumnValue::Bytes(vec![1, 2, 3]) + ); + } + + #[test] + fn convert_null() { + assert_eq!( + sqlite_value_to_column_value(Value::Null, SqlDataType::EXT_W_VARCHAR), + ColumnValue::Null + ); + } + + // ----------------------------------------------------------------------- + // Numeric datetime encodings (epoch seconds / Julian day) + // ----------------------------------------------------------------------- + // + // SQLite is dynamically typed: a column declared DATE/TIME/DATETIME/ + // TIMESTAMP is not guaranteed to hold text even though it is described to + // the application as an ODBC datetime SQL type. It may physically store + // an integer count of seconds since the epoch, or a floating point Julian + // day number. Both must decode to the same value a text representation + // would have produced. + + #[test] + fn epoch_seconds_integer_decodes_to_timestamp() { + // 1_700_000_000 == 2023-11-14 22:13:20 UTC (verified against Python's + // datetime.utcfromtimestamp). + let value = + sqlite_value_to_column_value(Value::Integer(1_700_000_000), SqlDataType::TIMESTAMP); + assert_eq!( + value, + ColumnValue::Timestamp { + year: 2023, + month: 11, + day: 14, + hour: 22, + minute: 13, + second: 20, + fraction: 0, + } + ); + } + + #[test] + fn epoch_seconds_integer_decodes_to_date_and_time() { + let date = sqlite_value_to_column_value(Value::Integer(1_700_000_000), SqlDataType::DATE); + assert_eq!( + date, + ColumnValue::Date { + year: 2023, + month: 11, + day: 14, + } + ); + let time = sqlite_value_to_column_value(Value::Integer(1_700_000_000), SqlDataType::TIME); + assert_eq!( + time, + ColumnValue::Time { + hour: 22, + minute: 13, + second: 20, + fraction: 0, + } + ); + } + + #[test] + fn julian_day_real_decodes_to_time_with_fraction() { + // 2451545.0 is 2000-01-01 12:00:00 UTC exactly (see + // julian_day_real_decodes_to_timestamp below); adding a quarter of a + // second's worth of days exercises the fractional-seconds path that + // only the REAL (Julian day) encoding can produce for TIME -- + // `decode_epoch_seconds` (INTEGER) never has a nonzero fraction to + // decode, so `DecodedDateTime::fraction` must be threaded through + // rather than dropped. + let julian_day = 2_451_545.0 + 0.25 / 86_400.0; + let time = sqlite_value_to_column_value(Value::Real(julian_day), SqlDataType::TIME); + match time { + ColumnValue::Time { + hour, + minute, + second, + fraction, + } => { + assert_eq!((hour, minute, second), (12, 0, 0)); + // f64 only has ~15-16 significant decimal digits; at this + // magnitude (~2.45 million), the round trip through + // `julian_day` cannot land on an exact nanosecond, so allow a + // small tolerance rather than asserting bit-for-bit equality. + assert!( + fraction.abs_diff(250_000_000) < 20_000, + "expected a fraction close to 250_000_000 ns, got {fraction}" + ); + } + other => panic!("expected ColumnValue::Time, got {other:?}"), + } + } + + #[test] + fn epoch_seconds_negative_decodes_pre_1970_dates() { + // -86_400 == exactly one day before the epoch: 1969-12-31 00:00:00 UTC. + let value = sqlite_value_to_column_value(Value::Integer(-86_400), SqlDataType::TIMESTAMP); + assert_eq!( + value, + ColumnValue::Timestamp { + year: 1969, + month: 12, + day: 31, + hour: 0, + minute: 0, + second: 0, + fraction: 0, + } + ); + } + + #[test] + fn julian_day_real_decodes_to_timestamp() { + // 2451545.0 == 2000-01-01 12:00:00 UTC exactly (SQLite: + // `SELECT julianday('2000-01-01 12:00:00')` returns 2451545.0), and + // the offset from the Unix epoch (10957.5 days) multiplies back to a + // whole number of seconds with no floating point rounding loss, so + // this case can assert exact fields. + let value = sqlite_value_to_column_value(Value::Real(2_451_545.0), SqlDataType::TIMESTAMP); + assert_eq!( + value, + ColumnValue::Timestamp { + year: 2000, + month: 1, + day: 1, + hour: 12, + minute: 0, + second: 0, + fraction: 0, + } + ); + } + + #[test] + fn julian_day_before_day_zero_still_decodes_when_year_fits_i16() { + // Julian day 0 is -4713-11-24 (proleptic Gregorian). A negative + // Julian day is a date further in the past still; this does not + // overflow SQL_TIMESTAMP_STRUCT.year (i16) since -4713 is well + // within range, and the decoder must not special-case it. + let value = sqlite_value_to_column_value(Value::Real(0.0), SqlDataType::DATE); + match value { + ColumnValue::Date { year, .. } => assert!(year < 0, "expected a BC year, got {year}"), + other => panic!("expected ColumnValue::Date, got {other:?}"), + } + } + + #[test] + fn epoch_seconds_year_overflow_falls_back_to_raw_integer() { + // i64::MAX seconds implies a year vastly beyond i16::MAX (32767); + // SQL_TIMESTAMP_STRUCT.year cannot represent it, so the fetch must + // not fail: the raw integer is returned instead, per the design + // decision that an unrepresentable value should still be readable + // (e.g. as SQL_C_SBIGINT) rather than aborting the whole fetch. + let value = sqlite_value_to_column_value(Value::Integer(i64::MAX), SqlDataType::TIMESTAMP); + assert_eq!(value, ColumnValue::I64(i64::MAX)); + } + + #[test] + fn julian_day_year_overflow_falls_back_to_raw_real() { + // An enormous Julian day number implies a year far beyond i16::MAX. + let value = sqlite_value_to_column_value(Value::Real(1.0e18), SqlDataType::TIMESTAMP); + assert_eq!(value, ColumnValue::F64(1.0e18)); + } + + #[test] + fn julian_day_non_finite_falls_back_to_raw_real() { + for jd in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let value = sqlite_value_to_column_value(Value::Real(jd), SqlDataType::TIMESTAMP); + match value { + ColumnValue::F64(f) if f.is_nan() && jd.is_nan() => {} + ColumnValue::F64(f) => assert_eq!(f, jd), + other => panic!("expected ColumnValue::F64({jd}), got {other:?}"), + } + } + } + + #[test] + fn non_temporal_integer_column_is_not_decoded_as_datetime() { + // A column that is NOT declared DATE/TIME/DATETIME/TIMESTAMP must + // never be reinterpreted as an encoded datetime, no matter what its + // numeric value looks like. + let value = + sqlite_value_to_column_value(Value::Integer(1_700_000_000), SqlDataType::EXT_BIG_INT); + assert_eq!(value, ColumnValue::I64(1_700_000_000)); + } + + #[test] + fn type_mapping_integer_variants() { + assert_eq!( + sqlite_type_to_sql_data_type("INTEGER"), + SqlDataType::EXT_BIG_INT + ); + assert_eq!( + sqlite_type_to_sql_data_type("INT"), + SqlDataType::EXT_BIG_INT + ); + assert_eq!( + sqlite_type_to_sql_data_type("BIGINT"), + SqlDataType::EXT_BIG_INT + ); + } + + #[test] + fn type_mapping_text_fallback() { + assert_eq!( + sqlite_type_to_sql_data_type("TEXT"), + SqlDataType::EXT_W_VARCHAR + ); + // "UNKNOWN" matches none of SQLite's affinity substrings (INT, CHAR/CLOB/TEXT, + // BLOB, REAL/FLOA/DOUB), so it falls through to NUMERIC affinity. + assert_eq!( + sqlite_type_to_sql_data_type("UNKNOWN"), + SqlDataType::DECIMAL + ); + } + + #[test] + fn type_mapping_real_variants() { + assert_eq!(sqlite_type_to_sql_data_type("REAL"), SqlDataType::DOUBLE); + assert_eq!(sqlite_type_to_sql_data_type("DOUBLE"), SqlDataType::DOUBLE); + assert_eq!(sqlite_type_to_sql_data_type("FLOAT"), SqlDataType::DOUBLE); + } + + #[test] + fn parameterised_types_are_recognised() { + assert_eq!( + sqlite_type_to_sql_data_type("VARCHAR(50)"), + SqlDataType::EXT_W_VARCHAR + ); + assert_eq!(sqlite_declared_type_precision("VARCHAR(50)"), 50); + assert_eq!( + sqlite_type_to_sql_data_type("DECIMAL(10,2)"), + SqlDataType::DECIMAL + ); + assert_eq!(sqlite_declared_type_precision("DECIMAL(10,2)"), 10); + assert_eq!(sqlite_declared_type_scale("DECIMAL(10,2)"), 2); + } + + #[test] + fn temporal_declared_types_are_recognised() { + // SqlDataType::DATE/TIME/TIMESTAMP are the ODBC 3.x concise types + // (91/92/93). odbc-sys has no EXT_TYPE_* spelling for them. + assert_eq!(sqlite_type_to_sql_data_type("DATE"), SqlDataType::DATE); + assert_eq!( + sqlite_type_to_sql_data_type("DATETIME"), + SqlDataType::TIMESTAMP + ); + assert_eq!( + sqlite_type_to_sql_data_type("TIMESTAMP"), + SqlDataType::TIMESTAMP + ); + } + + #[test] + fn declared_type_matching_is_case_insensitive_and_trims() { + assert_eq!( + sqlite_type_to_sql_data_type(" varchar(50) "), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn unrecognised_types_use_sqlite_affinity_rules() { + // SQLite's published affinity algorithm, applied in order as substring + // matches on the declared type. + assert_eq!( + sqlite_type_to_sql_data_type("UNSIGNED BIG INT"), // contains INT + SqlDataType::EXT_BIG_INT + ); + assert_eq!( + sqlite_type_to_sql_data_type("NATIVE CHARACTER(70)"), // contains CHAR + SqlDataType::EXT_W_VARCHAR + ); + assert_eq!( + sqlite_type_to_sql_data_type("DOUBLE PRECISION"), // contains DOUB + SqlDataType::DOUBLE + ); + assert_eq!( + sqlite_type_to_sql_data_type(""), // empty → BLOB affinity + SqlDataType::EXT_VAR_BINARY + ); + assert_eq!( + sqlite_type_to_sql_data_type("MADE UP TYPE"), // → NUMERIC affinity + SqlDataType::DECIMAL + ); + } + + #[test] + fn affinity_rules_are_applied_in_order() { + // "INT" is checked before "CHAR", so a type containing both is INTEGER. + assert_eq!( + sqlite_type_to_sql_data_type("INTCHAR"), + SqlDataType::EXT_BIG_INT + ); + // "CHAR" is checked before "BLOB". + assert_eq!( + sqlite_type_to_sql_data_type("CHARBLOB"), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn blob_has_a_non_zero_column_size() { + // A BLOB's declared-type precision must be non-zero, matching the + // BLOB row SQLGetTypeInfo reports; a COLUMN_SIZE of 0 would disagree. + assert!(sqlite_declared_type_precision("BLOB") > 0); + } + + #[test] + fn column_value_to_rusqlite_null() { + assert_eq!(column_value_to_rusqlite(&ColumnValue::Null), Value::Null); + } + + #[test] + fn column_value_to_rusqlite_decimal() { + // A bound SQL_C_NUMERIC arrives as ColumnValue::Decimal and must be + // stored as its exact text, never coerced to empty text. + assert_eq!( + column_value_to_rusqlite(&ColumnValue::Decimal("-123.45".into())), + Value::Text("-123.45".into()) + ); + } + + #[test] + fn column_value_to_rusqlite_string() { + assert_eq!( + column_value_to_rusqlite(&ColumnValue::String("hi".into())), + Value::Text("hi".into()) + ); + } + + #[test] + fn column_value_to_rusqlite_integers() { + assert_eq!( + column_value_to_rusqlite(&ColumnValue::I8(1)), + Value::Integer(1) + ); + assert_eq!( + column_value_to_rusqlite(&ColumnValue::I16(-5)), + Value::Integer(-5) + ); + assert_eq!( + column_value_to_rusqlite(&ColumnValue::I32(1000)), + Value::Integer(1000) + ); + assert_eq!( + column_value_to_rusqlite(&ColumnValue::I64(i64::MAX)), + Value::Integer(i64::MAX) + ); + } + + #[test] + fn column_value_to_rusqlite_floats() { + assert_eq!( + column_value_to_rusqlite(&ColumnValue::F32(1.5)), + Value::Real(1.5f32 as f64) + ); + assert_eq!( + column_value_to_rusqlite(&ColumnValue::F64(1.5_f64)), + Value::Real(1.5_f64) + ); + } + + #[test] + fn column_value_to_rusqlite_bool() { + assert_eq!( + column_value_to_rusqlite(&ColumnValue::Bool(true)), + Value::Integer(1) + ); + assert_eq!( + column_value_to_rusqlite(&ColumnValue::Bool(false)), + Value::Integer(0) + ); + } + + #[test] + fn column_value_to_rusqlite_bytes() { + assert_eq!( + column_value_to_rusqlite(&ColumnValue::Bytes(vec![0xDE, 0xAD])), + Value::Blob(vec![0xDE, 0xAD]) + ); + } + + #[test] + fn column_value_to_rusqlite_date() { + let v = ColumnValue::Date { + year: 2024, + month: 3, + day: 15, + }; + assert_eq!( + column_value_to_rusqlite(&v), + Value::Text("2024-03-15".into()) + ); + } + + #[test] + fn column_value_to_rusqlite_time() { + let v = ColumnValue::Time { + hour: 14, + minute: 30, + second: 5, + fraction: 0, + }; + assert_eq!( + column_value_to_rusqlite(&v), + Value::Text("14:30:05.000000000".into()) + ); + } + + #[test] + fn column_value_to_rusqlite_time_with_fraction() { + let v = ColumnValue::Time { + hour: 14, + minute: 30, + second: 5, + fraction: 123_000_000, + }; + assert_eq!( + column_value_to_rusqlite(&v), + Value::Text("14:30:05.123000000".into()) + ); + } + + #[test] + fn column_value_to_rusqlite_timestamp() { + let v = ColumnValue::Timestamp { + year: 2024, + month: 1, + day: 2, + hour: 10, + minute: 0, + second: 0, + fraction: 123_000_000, + }; + assert_eq!( + column_value_to_rusqlite(&v), + Value::Text("2024-01-02 10:00:00.123000000".into()) + ); + } +} + +#[cfg(test)] +mod proptests { + use proptest::prelude::*; + + use super::*; + + proptest! { + // The declared-type parsers must never panic on any input. + #[test] + fn declared_type_parsers_never_panic(s in ".*") { + let _ = sqlite_type_to_sql_data_type(&s); + let _ = sqlite_declared_type_precision(&s); + let _ = sqlite_declared_type_scale(&s); + } + + // A declared `VARCHAR(n)` reports n as its precision. + #[test] + fn declared_precision_round_trips(n in 0u32..1_000_000) { + prop_assert_eq!(sqlite_declared_type_precision(&format!("VARCHAR({n})")), n); + } + + // `DECIMAL(p,s)` reports s as its scale (the second parenthesised arg). + #[test] + fn declared_scale_round_trips(p in 0u32..1000, s in 0i16..1000) { + prop_assert_eq!(sqlite_declared_type_scale(&format!("DECIMAL({p},{s})")), s); + } + } +} From 6402d87b5031cbeb57e6ee4e5f47871270c9d4d5 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Sun, 26 Jul 2026 18:01:39 +0200 Subject: [PATCH 02/50] fix: adapt the driver to stackable-odbc-core's newer API default_get_info and common_get_info_raw are now generic over the backend, and the SQLEndTran cursor behaviours are derived from two new Backend hooks rather than hard-coded. SqliteBackend overrides both hooks explicitly rather than inheriting the Preserve default, because here the value is observable: this driver reports SQL_TC_DML and implements real transactions, where the Trino driver reports SQL_TC_NONE and never reaches the question. Preserve is correct for a reason specific to this driver rather than to SQLite. exec_direct materialises result sets eagerly, so no rusqlite::Statement is live when end_tran runs: COMMIT cannot fail with SQLITE_BUSY on a pending write, and ROLLBACK cannot abort a pending read with SQLITE_ABORT. Raw SQLite would make rollback SQL_CB_CLOSE. A test pins both values through the FFI entry point so a move to lazy streaming fails loudly. This changes what applications observe. SQL_CURSOR_COMMIT_BEHAVIOR previously reported SQL_CB_DELETE, which was never true -- core advertised it and implemented nothing, so the driver claimed to destroy cursors on commit while preserving them. Three tests called SQLCloseCursor after an INSERT and asserted success. Core now returns 24000 there, correctly: a statement producing no result set never opens a cursor, so there is nothing to close and the handle is already reusable. The calls were never necessary and are removed; one new test covers the corrected behaviour directly. Co-Authored-By: Claude Opus 5 (1M context) --- src/backend.rs | 34 ++++++++++- src/backend/info.rs | 16 +++--- src/ffi_integration_tests.rs | 106 ++++++++++++++++++++++++++++++----- 3 files changed, 134 insertions(+), 22 deletions(-) diff --git a/src/backend.rs b/src/backend.rs index 707af4a..4131d7d 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -4,7 +4,10 @@ use snafu::Snafu; use stackable_odbc_core::{ backend::Backend, errors::OdbcError, - types::{ColumnDescriptor, ColumnValue, ConnectParams, ExecuteOutcome, InfoValue, TypeInfoRow}, + types::{ + ColumnDescriptor, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, InfoValue, + TypeInfoRow, + }, }; mod execute; @@ -294,6 +297,35 @@ impl Backend for SqliteBackend { Ok(()) } + /// `Preserve` for both commit and rollback. + /// + /// This driver materialises every result set eagerly + /// (`execute::exec_direct`), so no `rusqlite::Statement` is live when + /// `end_tran` runs and neither SQLite failure mode is reachable: COMMIT + /// cannot hit `SQLITE_BUSY` on a pending write, and ROLLBACK cannot abort + /// a pending read. The materialised rows and the cursor index survive both + /// untouched. + /// + /// Raw SQLite is stricter than that. From 3.7.11 a ROLLBACK aborts pending + /// statements with `SQLITE_ABORT`, which would make rollback + /// `SQL_CB_CLOSE`. The value below is a property of this driver's + /// architecture, not of SQLite. + /// + /// If result sets ever become lazily streamed, revisit both hooks — and + /// note that `SQL_CB_CLOSE` would then also require a real + /// [`StatementBackend::close_cursor`]. + /// + /// Spec: + fn cursor_commit_behavior() -> CursorBehavior { + CursorBehavior::Preserve + } + + /// See [`SqliteBackend::cursor_commit_behavior`] — same reasoning, same + /// value. + fn cursor_rollback_behavior() -> CursorBehavior { + CursorBehavior::Preserve + } + // --- Delegations --- fn exec_direct(conn: &SqliteConnection, sql: &str) -> Result { diff --git a/src/backend/info.rs b/src/backend/info.rs index ddda50d..74e6106 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -635,11 +635,10 @@ fn sqlite_get_info(info_type: InfoType) -> Result { } // Fall through to shared defaults - default_get_info(info_type, &SqliteBackend::catalog_result_column_widths()).ok_or_else(|| { - SqliteError::NotImplemented { + default_get_info::(info_type, &SqliteBackend::catalog_result_column_widths()) + .ok_or_else(|| SqliteError::NotImplemented { feature: format!("get_info({info_type:?})"), - } - }) + }) } pub(super) fn get_info( @@ -825,7 +824,7 @@ pub(super) fn get_info_raw( // since 3.39.0; this build is 3.53.2). SQL_LIKE_ESCAPE_CLAUSE => Some(Ok(InfoValue::String("Y".into()))), SQL_OUTER_JOINS => Some(Ok(InfoValue::String("Y".into()))), - _ => common_get_info_raw(info_type).map(Ok), + _ => common_get_info_raw::(info_type).map(Ok), } } @@ -955,7 +954,7 @@ mod tests { } use super::*; use stackable_odbc_core::types::{ - DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_CA1_NEXT, + DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_CA1_NEXT, SQL_CB_PRESERVE, SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, @@ -1010,7 +1009,10 @@ mod tests { (InfoType::MaxDriverConnections, Expected::U16(0)), (InfoType::MaxConcurrentActivities, Expected::U16(0)), (InfoType::ConcatNullBehavior, Expected::U16(0)), - (InfoType::CursorCommitBehaviour, Expected::U16(0)), + // SQL_CB_PRESERVE (2), derived from Backend::cursor_commit_behavior. + // Not SQL_CB_DELETE: this driver materialises result sets eagerly, so + // SQLEndTran cannot disturb an open cursor. See the hook in backend.rs. + (InfoType::CursorCommitBehaviour, Expected::U16(SQL_CB_PRESERVE)), (InfoType::IdentifierCase, Expected::U16(SQL_IC_MIXED)), (InfoType::MaxColumnNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), (InfoType::MaxCursorNameLen, Expected::U16(SQL_MAX_CURSOR_NAME_LEN)), diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 34e049c..14b0d9f 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -1323,11 +1323,8 @@ fn exec_direct_insert_then_select_roundtrip() { ); assert_eq!(row_count, 1); - // Close the DML cursor so we can issue the SELECT on the same handle. - assert_eq!( - ffi::cursor::sql_close_cursor::(stmt), - SqlReturn::SUCCESS - ); + // No SQLCloseCursor here: an INSERT produces no result set, so no + // cursor is open and the SELECT can reuse this handle directly. // SELECT — verify the inserted row is readable. assert_eq!( @@ -3348,10 +3345,8 @@ fn autocommit_off_then_rollback_discards_changes() { exec_direct(stmt, "INSERT INTO tx_test VALUES (1)"), SqlReturn::SUCCESS ); - assert_eq!( - ffi::cursor::sql_close_cursor::(stmt), - SqlReturn::SUCCESS - ); + // No SQLCloseCursor between the two INSERTs: neither opens a cursor, + // so the handle is immediately reusable. assert_eq!( exec_direct(stmt, "INSERT INTO tx_test VALUES (2)"), SqlReturn::SUCCESS @@ -3851,11 +3846,8 @@ fn data_at_execution_insert() { SqlReturn::SUCCESS ); - // Close cursor from the INSERT before issuing a SELECT on the same handle. - assert_eq!( - ffi::cursor::sql_close_cursor::(stmt), - SqlReturn::SUCCESS - ); + // No SQLCloseCursor after the INSERT: it produced no result set, so no + // cursor is open and the SELECT can reuse this handle directly. // Verify the inserted row via ODBC SELECT. assert_eq!( @@ -4579,3 +4571,89 @@ fn escape_fn_now_executes_as_sqlite_datetime() { cleanup(env, conn, stmt); } } + +// --------------------------------------------------------------------------- +// SQLEndTran cursor behaviour +// --------------------------------------------------------------------------- + +/// Pins the two `SQLEndTran` cursor-behaviour values through the real FFI entry +/// point, so that neither a change to `SqliteBackend`'s hooks nor a change to +/// `stackable-odbc-core`'s defaults can move them silently. +/// +/// Both are `SQL_CB_PRESERVE` because this driver materialises result sets +/// eagerly; see `SqliteBackend::cursor_commit_behavior` for why that, and not +/// SQLite's own semantics, decides the answer. SQLite would abort a pending +/// read on ROLLBACK (`SQLITE_ABORT`, >= 3.7.11), which would be +/// `SQL_CB_CLOSE` — but this driver never has one pending. +#[test] +fn end_tran_cursor_behaviour_is_preserve_for_commit_and_rollback() { + use stackable_odbc_core::types::{SQL_CB_PRESERVE, SQL_CURSOR_ROLLBACK_BEHAVIOR}; + + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // SQL_CURSOR_COMMIT_BEHAVIOR (23) has an InfoType variant. + assert_get_info_u16(conn, InfoType::CursorCommitBehaviour, SQL_CB_PRESERVE); + + // SQL_CURSOR_ROLLBACK_BEHAVIOR (24) has none, so it goes through + // get_info_raw and must be requested by its raw value. + let mut value: u16 = 0xDEAD; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::( + conn, + SQL_CURSOR_ROLLBACK_BEHAVIOR, + &mut value as *mut u16 as *mut c_void, + 2, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "SQL_CURSOR_ROLLBACK_BEHAVIOR"); + assert_eq!(str_len, 2, "SQL_CURSOR_ROLLBACK_BEHAVIOR string_length_ptr"); + assert_eq!( + value, SQL_CB_PRESERVE, + "SQL_CURSOR_ROLLBACK_BEHAVIOR must be SQL_CB_PRESERVE (2): no \ + rusqlite::Statement is live when end_tran runs, so ROLLBACK \ + cannot abort a pending read" + ); + + cleanup(env, conn, stmt); + } +} + +/// `SQLCloseCursor` after a DML statement returns 24000. An INSERT produces no +/// result set, so no cursor is ever open on that statement. +/// +/// `stackable-odbc-core` used to infer cursor state from whether a backend +/// statement existed, which let this succeed silently; it now tracks +/// `cursor_open` explicitly and rejects the call, which is what the ODBC +/// statement transition table requires. +#[test] +fn close_cursor_after_dml_returns_no_cursor_open() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + exec_direct(stmt, "CREATE TABLE dml_cc (v INTEGER)"), + SqlReturn::SUCCESS + ); + assert_eq!( + exec_direct(stmt, "INSERT INTO dml_cc VALUES (1)"), + SqlReturn::SUCCESS + ); + + // No result set, so no cursor: SQLSTATE 24000, invalid cursor state. + assert_eq!( + ffi::cursor::sql_close_cursor::(stmt), + SqlReturn::ERROR + ); + + // The handle is still usable — the rejected close changed nothing. + assert_eq!( + exec_direct(stmt, "SELECT v FROM dml_cc"), + SqlReturn::SUCCESS + ); + + cleanup(env, conn, stmt); + } +} From fc5c7e9f7d548a57e2cbe9cbb7987f9f681654ab Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Sun, 26 Jul 2026 18:03:10 +0200 Subject: [PATCH 03/50] chore: add the lint and dependency-policy configuration Carries over the workspace's clippy, markdownlint, pre-commit and cargo-deny configuration, de-workspaced: cargo test replaces cargo test --workspace, and the packaging output path loses its crates/ prefix. Drops the RUSTSEC-2024-0436 advisory ignore. It covered the unmaintained paste crate reaching the workspace through trino-rust-client, which is not in this driver's dependency tree. Adds a cargo-sort hook, matching the sibling driver repository. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 25 +++++++++++++++ .markdownlint.yaml | 28 +++++++++++++++++ .pre-commit-config.yaml | 70 +++++++++++++++++++++++++++++++++++++++++ clippy.toml | 2 ++ deny.toml | 38 ++++++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 .gitignore create mode 100644 .markdownlint.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 clippy.toml create mode 100644 deny.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9547902 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +debug/ +target/ +**/*.rs.bk +.worktrees/ + +.idea/ +*.iws +*.iml +.vscode/ + +# Generated via ctags -R. +tags + +# Local agent working notes (SDD reports); never part of the shipped tree +.superpowers/ + +# Release packaging output +packaging/dist/ + +# Generated by test/setup.sh +test/test.db + +# Python bytecode from the test scripts +__pycache__/ +*.pyc diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..783004c --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,28 @@ +--- +# All defaults or options can be checked here: +# https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml + +# Default state for all rules +default: true + +# MD013/line-length - Line length +MD013: + # Number of characters + line_length: 9999 + # Number of characters for headings + heading_line_length: 9999 + # Number of characters for code blocks + code_block_line_length: 9999 + +# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content +MD024: + # Only check sibling headings + siblings_only: true + +# MD040/fenced-code-language - Fenced code blocks should have a language specified +# We use plain fenced blocks for ODBC config files and output examples +MD040: false + +# MD060/table-column-style - Table column alignment +# Too strict for our tables +MD060: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e3a53a0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,70 @@ +--- +default_language_version: + node: system + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # 5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: mixed-line-ending + - id: detect-aws-credentials + args: ["--allow-missing-credentials"] + - id: detect-private-key + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: 192ad822316c3a22fb3d3cc8aa6eafa0b8488360 # 0.45.0 + hooks: + - id: markdownlint + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: 2491238703a5d3415bb2b7ff11388bf775372f29 # 0.10.0 + hooks: + - id: shellcheck + args: ["--severity=info"] + + - repo: local + hooks: + - id: cargo-test + name: cargo-test + language: system + entry: cargo test + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$|Cargo\.(toml|lock) + + - id: cargo-rustfmt + name: cargo-rustfmt + language: system + entry: cargo fmt --all -- --check + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$ + + - id: cargo-clippy + name: cargo-clippy + language: system + entry: cargo clippy --all-targets -- -D warnings + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$ + + - id: cargo-sort + name: cargo-sort + language: system + entry: cargo sort --grouped --check + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: Cargo\.toml$ + + - id: cargo-deny + name: cargo-deny + language: system + entry: cargo deny check + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: Cargo\.(toml|lock)|deny\.toml diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..f69b4a6 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +allow-unwrap-in-tests = true +allow-panic-in-tests = true diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..a11bdd7 --- /dev/null +++ b/deny.toml @@ -0,0 +1,38 @@ +# Cargo deny configuration for stackable-odbc-sqlite +# Based on operator-rs conventions. +# Run: cargo deny check + +[graph] +targets = [ + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "aarch64-unknown-linux-gnu" }, + { triple = "x86_64-pc-windows-gnu" }, +] + +[advisories] +yanked = "deny" + +[bans] +multiple-versions = "allow" + +[licenses] +unused-allowed-license = "allow" +confidence-threshold = 1.0 +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", + "Unlicense", +] +private = { ignore = true } + +[sources] +unknown-registry = "deny" +unknown-git = "deny" From ed8bc76a31f521b4922c7a1be40c124eef65deb9 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Sun, 26 Jul 2026 18:04:01 +0200 Subject: [PATCH 04/50] chore: move the release packaging scripts The install and uninstall scripts move unchanged. build-archives.sh loses its workspace assumptions: the crate-relative and repository-root paths collapse into one derived from the script's own location, the dist directory drops its crates/ prefix, and the cargo invocations in the error messages drop -p. Version strings in the packaging README are set to 0.0.1 in the forms release.toml's pre-release-replacements expect, and the support link points at this repository. Co-Authored-By: Claude Opus 5 (1M context) --- packaging/README.md | 132 ++++++++++++++++++++++++++++++++ packaging/build-archives.sh | 69 +++++++++++++++++ packaging/linux/install.sh | 40 ++++++++++ packaging/linux/uninstall.sh | 19 +++++ packaging/windows/install.bat | 32 ++++++++ packaging/windows/uninstall.bat | 21 +++++ 6 files changed, 313 insertions(+) create mode 100644 packaging/README.md create mode 100755 packaging/build-archives.sh create mode 100755 packaging/linux/install.sh create mode 100755 packaging/linux/uninstall.sh create mode 100644 packaging/windows/install.bat create mode 100644 packaging/windows/uninstall.bat diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..a852503 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,132 @@ +# Stackable SQLite ODBC Driver + +ODBC 3.x driver for SQLite, primarily intended for testing and for +exercising the Stackable ODBC framework on a lightweight backend. + +## Building from source + +To produce the release archives yourself, run the following from the +**repository root**: + +```bash +# One-time: add the Windows cross-compilation target +rustup target add x86_64-pc-windows-gnu + +# Build the Linux and Windows binaries +cargo build --release +cargo build --release --target x86_64-pc-windows-gnu + +# Package into release archives (replace the version as appropriate) +VERSION=0.0.1 ./packaging/build-archives.sh +``` + +This produces two files in `packaging/dist/`: + +- `stackable-odbc-sqlite--linux-x64.tar.gz` +- `stackable-odbc-sqlite--windows-x64.zip` + +To install on Linux, extract and run the install script: + +```bash +mkdir /tmp/sqlite-odbc +tar xzf stackable-odbc-sqlite-0.0.1-linux-x64.tar.gz -C /tmp/sqlite-odbc +cd /tmp/sqlite-odbc +sudo ./install.sh +``` + +On Windows, extract the `.zip` and run `install.bat` from an Administrator +Command Prompt. See the installation instructions below for details. + +## Installation + +> **Note:** These instructions assume you are working from an extracted +> release archive, where the driver binary sits alongside the install +> scripts. If you are working from a source checkout, build the archives +> first (see above). + +### Linux (x86_64) + +Requires `unixODBC` (`unixodbc` package) and root privileges for +`odbcinst` registration. + +```bash +sudo ./install.sh +``` + +Verify with `odbcinst -q -d` — the output should include +`[stackable_odbc_sqlite]`. + +To uninstall: + +```bash +sudo ./uninstall.sh +``` + +If you created any DSNs, also remove them from `/etc/odbc.ini` (or +`~/.odbc.ini`). + +### Windows (x86_64) + +Open an **Administrator** Command Prompt (`cmd.exe`), then: + +```cmd +install.bat +``` + +Verify with the ODBC Data Source Administrator +(`%SystemRoot%\System32\odbcad32.exe`) — the Drivers tab should list +`stackable_odbc_sqlite`. + +To uninstall: + +```cmd +uninstall.bat +``` + +If you created any DSNs, also remove them via the registry: + +```cmd +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\YourDsnName" /f +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "YourDsnName" /f +``` + +## Create a DSN (optional) + +A DSN stores connection parameters so that users don't need the full +connection string each time. This step is optional — DSN-less connection +strings (shown below) work without it. + +On Windows (`cmd.exe`): + +```cmd +odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=SQLite Test|Database=C:\data\test.db|"} +``` + +> **PowerShell users:** `odbcconf.exe` commands with `{...}` use `cmd.exe` +> syntax. In PowerShell, wrap the argument in single quotes: +> `odbcconf.exe /A '{CONFIGDSN ...}'`. + +The DSN will appear under the **User DSN** tab in ODBC Data Source +Administrator. Note: the driver has no GUI dialog, so DSNs must be +created via `odbcconf` or the registry, not the "Add" button. + +On Linux, add a section to `/etc/odbc.ini` (or `~/.odbc.ini` for a +per-user DSN): + +```ini +[SQLite Test] +Driver = stackable_odbc_sqlite +Database = /path/to/your.db +``` + +## Connection string + +DSN-less: + +``` +Driver=stackable_odbc_sqlite;Database=/path/to/your.db +``` + +## Support + + diff --git a/packaging/build-archives.sh b/packaging/build-archives.sh new file mode 100755 index 0000000..fc63dd8 --- /dev/null +++ b/packaging/build-archives.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Assemble release archives for stackable-odbc-sqlite. +# +# Preconditions: +# - $VERSION environment variable set (e.g. "1.0.0-beta.1") +# - target/release/libstackable_odbc_sqlite.so exists +# - target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll exists +# +# Output (written to dist/): +# - stackable-odbc-sqlite--linux-x64.tar.gz +# - stackable-odbc-sqlite--windows-x64.zip +set -euo pipefail + +: "${VERSION:?VERSION environment variable must be set}" + +PACKAGING_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$PACKAGING_DIR/.." && pwd)" +DIST_DIR="$PACKAGING_DIR/dist" +LINUX_SO="$REPO_ROOT/target/release/libstackable_odbc_sqlite.so" +WINDOWS_DLL="$REPO_ROOT/target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll" +LICENSE_FILE="$REPO_ROOT/LICENSE" + +if [ ! -f "$LINUX_SO" ]; then + echo "ERROR: $LINUX_SO not found. Run 'cargo build --release' first." >&2 + exit 1 +fi +if [ ! -f "$WINDOWS_DLL" ]; then + echo "ERROR: $WINDOWS_DLL not found. Run 'cargo build --release --target x86_64-pc-windows-gnu' first." >&2 + exit 1 +fi +if [ ! -f "$LICENSE_FILE" ]; then + echo "ERROR: LICENSE file not found at $LICENSE_FILE" >&2 + exit 1 +fi + +mkdir -p "$DIST_DIR" + +# --- Linux archive --- +LINUX_STAGING="$DIST_DIR/staging-linux" +rm -rf "$LINUX_STAGING" +mkdir -p "$LINUX_STAGING" +cp "$LINUX_SO" "$LINUX_STAGING/" +cp "$PACKAGING_DIR/linux/install.sh" "$LINUX_STAGING/" +cp "$PACKAGING_DIR/linux/uninstall.sh" "$LINUX_STAGING/" +cp "$PACKAGING_DIR/README.md" "$LINUX_STAGING/" +cp "$LICENSE_FILE" "$LINUX_STAGING/" +chmod +x "$LINUX_STAGING/install.sh" "$LINUX_STAGING/uninstall.sh" + +LINUX_ARCHIVE="stackable-odbc-sqlite-${VERSION}-linux-x64.tar.gz" +tar -czf "$DIST_DIR/$LINUX_ARCHIVE" -C "$LINUX_STAGING" . +rm -rf "$LINUX_STAGING" + +# --- Windows archive --- +WINDOWS_STAGING="$DIST_DIR/staging-windows" +rm -rf "$WINDOWS_STAGING" +mkdir -p "$WINDOWS_STAGING" +cp "$WINDOWS_DLL" "$WINDOWS_STAGING/" +cp "$PACKAGING_DIR/windows/install.bat" "$WINDOWS_STAGING/" +cp "$PACKAGING_DIR/windows/uninstall.bat" "$WINDOWS_STAGING/" +cp "$PACKAGING_DIR/README.md" "$WINDOWS_STAGING/" +cp "$LICENSE_FILE" "$WINDOWS_STAGING/" + +WINDOWS_ARCHIVE="stackable-odbc-sqlite-${VERSION}-windows-x64.zip" +(cd "$WINDOWS_STAGING" && zip -r "$DIST_DIR/$WINDOWS_ARCHIVE" .) +rm -rf "$WINDOWS_STAGING" + +echo "Built:" +echo " $DIST_DIR/$LINUX_ARCHIVE" +echo " $DIST_DIR/$WINDOWS_ARCHIVE" diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 0000000..d9bb9d7 --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Install the Stackable SQLite ODBC driver on Linux. +# Must be run as root (or via sudo). +# INSTALL_DIR environment variable overrides the default install path. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/lib/stackable-odbc}" +DRIVER_LIB="libstackable_odbc_sqlite.so" + +if [ "$EUID" -ne 0 ]; then + echo "This script must be run as root (or via sudo)." >&2 + exit 1 +fi + +if [ ! -f "$LIB_DIR/$DRIVER_LIB" ]; then + echo "ERROR: $DRIVER_LIB not found next to install.sh at $LIB_DIR" >&2 + exit 1 +fi + +mkdir -p "$INSTALL_DIR" +cp "$LIB_DIR/$DRIVER_LIB" "$INSTALL_DIR/" + +TMP_INI="$(mktemp)" +trap 'rm -f "$TMP_INI"' EXIT +cat > "$TMP_INI" <&2 + exit 1 +fi + +odbcinst -u -d -n "stackable_odbc_sqlite" || true +rm -f "$INSTALL_DIR/$DRIVER_LIB" +rmdir --ignore-fail-on-non-empty "$INSTALL_DIR" 2>/dev/null || true + +echo "Stackable SQLite ODBC driver uninstalled." +echo "If you created any DSNs, remove them from /etc/odbc.ini (or ~/.odbc.ini)." diff --git a/packaging/windows/install.bat b/packaging/windows/install.bat new file mode 100644 index 0000000..6ff6de7 --- /dev/null +++ b/packaging/windows/install.bat @@ -0,0 +1,32 @@ +@echo off +rem Install the Stackable SQLite ODBC driver on Windows. +rem Must be run from an Administrator Command Prompt. +setlocal + +set "INSTALL_DIR=%ProgramFiles%\Stackable\ODBC" +set "DRIVER_DLL=stackable_odbc_sqlite.dll" + +if not exist "%~dp0%DRIVER_DLL%" ( + echo ERROR: %DRIVER_DLL% not found next to install.bat. + exit /b 1 +) + +if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%" + +copy /Y "%~dp0%DRIVER_DLL%" "%INSTALL_DIR%\" >nul +if errorlevel 1 ( + echo ERROR: Failed to copy DLL. Are you running as Administrator? + exit /b 1 +) + +odbcconf.exe /A {INSTALLDRIVER "stackable_odbc_sqlite|Driver=%INSTALL_DIR%\%DRIVER_DLL%|Setup=%INSTALL_DIR%\%DRIVER_DLL%|"} +if errorlevel 1 ( + echo ERROR: Driver registration failed. + exit /b 1 +) + +echo Stackable SQLite ODBC driver installed to %INSTALL_DIR%. +echo Verify with: ODBC Data Source Administrator (odbcad32.exe) +echo. +echo To create a DSN (optional), see README.md. +endlocal diff --git a/packaging/windows/uninstall.bat b/packaging/windows/uninstall.bat new file mode 100644 index 0000000..89aede7 --- /dev/null +++ b/packaging/windows/uninstall.bat @@ -0,0 +1,21 @@ +@echo off +rem Uninstall the Stackable SQLite ODBC driver on Windows. +rem Must be run from an Administrator Command Prompt (cmd.exe). +setlocal + +set "INSTALL_DIR=%ProgramFiles%\Stackable\ODBC" +set "DRIVER_DLL=stackable_odbc_sqlite.dll" +set "DRIVER_NAME=stackable_odbc_sqlite" + +rem Remove the driver registration from the registry. +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\%DRIVER_NAME%" /f >nul 2>&1 +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "%DRIVER_NAME%" /f >nul 2>&1 + +if exist "%INSTALL_DIR%\%DRIVER_DLL%" del /F /Q "%INSTALL_DIR%\%DRIVER_DLL%" + +echo Stackable SQLite ODBC driver uninstalled. +echo. +echo If you created any DSNs, remove them with: +echo reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\YourDsnName" /f +echo reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "YourDsnName" /f +endlocal From c301d98a3c3539a1643a165ae7fbe1052b9c37ec Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Sun, 26 Jul 2026 18:05:09 +0200 Subject: [PATCH 05/50] chore: move the SQLite integration test suite The suite moves from test/sqlite/ to test/, with the directory prefix and the -p crate selectors dropped from the setup and run scripts and from the usage docstrings. PROJECT_DIR is derived from one parent directory rather than two: the scripts sat two levels below the workspace root and now sit one below the repository root. Only the tracked files move. odbc.ini, odbcinst.ini and test.db are generated by setup.sh and stay ignored; the checked-in copies held absolute paths into the old workspace. Co-Authored-By: Claude Opus 5 (1M context) --- test/.gitignore | 3 + test/create_test_db.sql | 16 ++ test/run-tests.sh | 60 ++++++ test/setup.sh | 39 ++++ test/test_integration.py | 358 ++++++++++++++++++++++++++++++++++ test/windows_test.py | 406 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 882 insertions(+) create mode 100644 test/.gitignore create mode 100644 test/create_test_db.sql create mode 100755 test/run-tests.sh create mode 100755 test/setup.sh create mode 100755 test/test_integration.py create mode 100644 test/windows_test.py diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..4dcdabd --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,3 @@ +test.db +odbcinst.ini +odbc.ini diff --git a/test/create_test_db.sql b/test/create_test_db.sql new file mode 100644 index 0000000..747a686 --- /dev/null +++ b/test/create_test_db.sql @@ -0,0 +1,16 @@ +CREATE TABLE types_test ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + price REAL, + quantity INTEGER, + active BOOLEAN, + data BLOB, + created_at TEXT +); +INSERT INTO types_test VALUES (1, 'Widget', 9.99, 100, 1, X'DEADBEEF', '2026-01-15T10:30:00'); +INSERT INTO types_test VALUES (2, 'Gadget', 24.50, NULL, 0, NULL, '2026-02-20T14:00:00'); +INSERT INTO types_test VALUES (3, 'Doohickey', 0.50, 9999, 1, X'00', '2026-03-01T00:00:00'); + +CREATE TABLE empty_table (id INTEGER PRIMARY KEY, value TEXT); + +CREATE VIEW types_view AS SELECT id, name, price FROM types_test WHERE active = 1; diff --git a/test/run-tests.sh b/test/run-tests.sh new file mode 100755 index 0000000..d010324 --- /dev/null +++ b/test/run-tests.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Runs SQLite integration tests (Linux) and optionally Windows VM tests. +# +# Requires setup.sh to have been run first. +# +# Usage: +# ./test/run-tests.sh # Linux tests only +# ./test/run-tests.sh --windows # Linux + Windows VM tests +# ./test/run-tests.sh --skip-build # skip the cargo build (also passed to windows_test.py) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +DRIVER_PATH="$PROJECT_DIR/target/debug/libstackable_odbc_sqlite.so" +DB_PATH="$SCRIPT_DIR/test.db" + +RUN_WINDOWS=false +SKIP_BUILD=false +WINDOWS_EXTRA_ARGS=() + +for arg in "$@"; do + case "$arg" in + --windows) RUN_WINDOWS=true ;; + --skip-build) SKIP_BUILD=true; WINDOWS_EXTRA_ARGS+=("$arg") ;; + *) WINDOWS_EXTRA_ARGS+=("$arg") ;; + esac +done + +# --- Rebuild the driver --- +# Only setup.sh built the .so, so editing driver source and re-running this +# script would silently test the previous build. `cargo test` below builds the +# test harness, not the cdylib that pyodbc loads, so an explicit build is +# needed. cargo is incremental, so this is a no-op when nothing changed. +if [[ "$SKIP_BUILD" == false ]]; then + echo "=== Building stackable-odbc-sqlite ===" + (cd "$PROJECT_DIR" && cargo build) +fi + +# --- Linux: pyodbc integration tests (2 configs, matching Windows) --- +export ODBCSYSINI="$SCRIPT_DIR" +export ODBCINI="$SCRIPT_DIR/odbc.ini" + +echo "=== Running Linux pyodbc integration tests (DSN-less) ===" +uv run --with pyodbc python3 "$SCRIPT_DIR/test_integration.py" \ + "Driver=$DRIVER_PATH;Database=$DB_PATH" + +echo "=== Running Linux pyodbc integration tests (DSN) ===" +uv run --with pyodbc python3 "$SCRIPT_DIR/test_integration.py" "DSN=test_sqlite" + +# --- Linux: Rust FFI integration tests --- +echo "=== Running SQLite FFI integration tests ===" +cd "$PROJECT_DIR" +cargo test + +# --- Windows VM tests (optional) --- +if [[ "$RUN_WINDOWS" == true ]]; then + echo "=== Running Windows VM integration tests ===" + uv run --with pywinrm python3 "$SCRIPT_DIR/windows_test.py" "${WINDOWS_EXTRA_ARGS[@]+"${WINDOWS_EXTRA_ARGS[@]}"}" +fi diff --git a/test/setup.sh b/test/setup.sh new file mode 100755 index 0000000..cbdfba8 --- /dev/null +++ b/test/setup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TEST_DIR="$SCRIPT_DIR" + +echo "=== Building stackable-odbc-sqlite ===" +cd "$PROJECT_DIR" +cargo build + +DRIVER_PATH="$PROJECT_DIR/target/debug/libstackable_odbc_sqlite.so" +DB_PATH="$TEST_DIR/test.db" + +echo "=== Creating test database ===" +rm -f "$DB_PATH" +sqlite3 "$DB_PATH" < "$TEST_DIR/create_test_db.sql" + +echo "=== Writing ODBC configuration ===" +cat > "$TEST_DIR/odbcinst.ini" << EOF +[stackable_odbc_sqlite] +Driver = $DRIVER_PATH +EOF + +cat > "$TEST_DIR/odbc.ini" << EOF +[test_sqlite] +Driver = stackable_odbc_sqlite +Database = $DB_PATH +EOF + +echo "" +echo "=== Setup complete ===" +echo "" +echo "Run tests: ./test/run-tests.sh [--windows]" +echo "" +echo "To test interactively:" +echo " export ODBCSYSINI=$TEST_DIR" +echo " export ODBCINI=$TEST_DIR/odbc.ini" +echo " isql -3 test_sqlite -v" diff --git a/test/test_integration.py b/test/test_integration.py new file mode 100755 index 0000000..032f113 --- /dev/null +++ b/test/test_integration.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +Integration tests for the SQLite ODBC driver. + +Runs through the ODBC Driver Manager (unixODBC on Linux, odbc32.dll on Windows) +using pyodbc. Tests DDL, DML, queries, aggregation, joins, and parameterised +statements. + +Usage: + python3 test/test_integration.py "Driver=/path/to/driver.so;Database=/path/to/test.db" + python3 test/test_integration.py "Driver=C:\\path\\to\\driver.dll;Database=C:\\test.db" + +Requires: pip install pyodbc +""" + +import sys +import pyodbc + +passed = 0 +failed = 0 + + +def run(label, fn): + """Run a test function, print PASS/FAIL, track counts.""" + global passed, failed + try: + fn() + print(f"PASS {label}") + passed += 1 + except Exception as e: + print(f"FAIL {label}: {e}") + failed += 1 + + +def main(): + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(2) + + conn_str = sys.argv[1] + conn = pyodbc.connect(conn_str, autocommit=True) + cur = conn.cursor() + + # === Setup: create test tables === + cur.execute(""" + CREATE TABLE IF NOT EXISTS employees ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + salary REAL, + active BOOLEAN + ) + """) + cur.execute("DELETE FROM employees") + cur.execute("INSERT INTO employees VALUES (1, 'Alice', 75000.50, 1)") + cur.execute("INSERT INTO employees VALUES (2, 'Bob', 62000.00, 0)") + cur.execute("INSERT INTO employees VALUES (3, 'Charlie', 91000.25, 1)") + + cur.execute(""" + CREATE TABLE IF NOT EXISTS types_test ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + price REAL, + quantity INTEGER, + active BOOLEAN, + data BLOB, + created_at TEXT + ) + """) + cur.execute("DELETE FROM types_test") + cur.execute("INSERT INTO types_test VALUES (1, 'Widget', 9.99, 100, 1, X'DEADBEEF', '2026-01-15T10:30:00')") + cur.execute("INSERT INTO types_test VALUES (2, 'Gadget', 24.50, NULL, 0, NULL, '2026-02-20T14:00:00')") + cur.execute("INSERT INTO types_test VALUES (3, 'Doohickey', 0.50, 9999, 1, X'00', '2026-03-01T00:00:00')") + + # ------------------------------------------------------------------ + # SELECT basics + # ------------------------------------------------------------------ + def test_select_all(): + cur.execute("SELECT * FROM employees ORDER BY id") + rows = cur.fetchall() + assert len(rows) == 3, f"expected 3 rows, got {len(rows)}" + assert rows[0][1] == "Alice" + assert rows[1][1] == "Bob" + assert rows[2][1] == "Charlie" + + run("SELECT all rows", test_select_all) + + def test_select_where(): + cur.execute("SELECT name, salary FROM employees WHERE id = 1") + row = cur.fetchone() + assert row is not None + assert row[0] == "Alice" + assert abs(row[1] - 75000.50) < 0.01 + + run("SELECT with WHERE", test_select_where) + + def test_select_count(): + cur.execute("SELECT COUNT(*) FROM employees") + count = cur.fetchone()[0] + # SQLite COUNT(*) may come back as str depending on column type metadata + assert int(count) == 3, f"expected 3, got {count!r}" + + run("SELECT COUNT(*)", test_select_count) + + def test_select_empty(): + cur.execute("SELECT * FROM employees WHERE id = 999") + assert cur.fetchone() is None + + run("SELECT with no matching rows", test_select_empty) + + # ------------------------------------------------------------------ + # DDL + DML + # ------------------------------------------------------------------ + def test_create_insert_drop(): + cur.execute("DROP TABLE IF EXISTS temp_test") + cur.execute("CREATE TABLE temp_test (id INTEGER PRIMARY KEY, val TEXT)") + cur.execute("INSERT INTO temp_test VALUES (1, 'hello')") + cur.execute("INSERT INTO temp_test VALUES (2, 'world')") + cur.execute("SELECT COUNT(*) FROM temp_test") + assert int(cur.fetchone()[0]) == 2 + cur.execute("DROP TABLE temp_test") + + run("CREATE + INSERT + DROP", test_create_insert_drop) + + def test_insert_row_count(): + cur.execute("DROP TABLE IF EXISTS rc_test") + cur.execute("CREATE TABLE rc_test (id INTEGER PRIMARY KEY, val TEXT)") + count = cur.execute("INSERT INTO rc_test VALUES (1, 'a')").rowcount + assert count == 1, f"expected rowcount 1, got {count}" + cur.execute("DROP TABLE rc_test") + + run("INSERT rowcount", test_insert_row_count) + + def test_update(): + cur.execute("UPDATE employees SET salary = 80000.00 WHERE name = 'Alice'") + cur.execute("SELECT salary FROM employees WHERE name = 'Alice'") + assert abs(cur.fetchone()[0] - 80000.00) < 0.01 + # restore + cur.execute("UPDATE employees SET salary = 75000.50 WHERE name = 'Alice'") + + run("UPDATE + verify", test_update) + + def test_update_row_count(): + count = cur.execute("UPDATE employees SET salary = salary WHERE active = 1").rowcount + assert count == 2, f"expected rowcount 2, got {count}" + + run("UPDATE rowcount", test_update_row_count) + + def test_delete(): + cur.execute("INSERT INTO employees VALUES (99, 'Temp', 10000, 1)") + count = cur.execute("DELETE FROM employees WHERE id = 99").rowcount + assert count == 1, f"expected rowcount 1, got {count}" + cur.execute("SELECT * FROM employees WHERE id = 99") + assert cur.fetchone() is None + + run("DELETE + verify", test_delete) + + # ------------------------------------------------------------------ + # Aggregation + # ------------------------------------------------------------------ + def test_group_by(): + cur.execute("DROP TABLE IF EXISTS orders") + cur.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)") + for row in [(1,'Alice',29.99),(2,'Bob',49.99),(3,'Alice',49.99),(4,'Bob',29.99),(5,'Alice',99.99)]: + cur.execute("INSERT INTO orders VALUES (?,?,?)", row) + cur.execute("SELECT customer, COUNT(*), SUM(amount) FROM orders GROUP BY customer ORDER BY customer") + rows = cur.fetchall() + assert len(rows) == 2 + assert rows[0][0] == "Alice" + assert int(rows[0][1]) == 3 + assert abs(float(rows[0][2]) - 179.97) < 0.01 + assert rows[1][0] == "Bob" + assert int(rows[1][1]) == 2 + cur.execute("DROP TABLE orders") + + run("GROUP BY + COUNT + SUM", test_group_by) + + def test_having(): + cur.execute("DROP TABLE IF EXISTS orders2") + cur.execute("CREATE TABLE orders2 (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)") + for row in [(1,'Alice',10),(2,'Alice',20),(3,'Bob',30)]: + cur.execute("INSERT INTO orders2 VALUES (?,?,?)", row) + cur.execute("SELECT customer, COUNT(*) AS cnt FROM orders2 GROUP BY customer HAVING cnt > 1") + rows = cur.fetchall() + assert len(rows) == 1 + assert rows[0][0] == "Alice" + cur.execute("DROP TABLE orders2") + + run("GROUP BY + HAVING", test_having) + + def test_order_by(): + cur.execute("SELECT name FROM employees ORDER BY salary DESC") + names = [r[0] for r in cur.fetchall()] + assert names == ["Charlie", "Alice", "Bob"], f"got {names}" + + run("ORDER BY DESC", test_order_by) + + # ------------------------------------------------------------------ + # JOIN + # ------------------------------------------------------------------ + def test_join(): + cur.execute("DROP TABLE IF EXISTS departments") + cur.execute("CREATE TABLE departments (id INTEGER PRIMARY KEY, dept TEXT)") + cur.execute("INSERT INTO departments VALUES (1, 'Engineering')") + cur.execute("INSERT INTO departments VALUES (2, 'Marketing')") + cur.execute(""" + SELECT e.name, d.dept + FROM employees e JOIN departments d ON e.id = d.id + ORDER BY e.id + """) + rows = cur.fetchall() + assert len(rows) == 2 + assert rows[0][0] == "Alice" and rows[0][1] == "Engineering" + assert rows[1][0] == "Bob" and rows[1][1] == "Marketing" + cur.execute("DROP TABLE departments") + + run("JOIN", test_join) + + # ------------------------------------------------------------------ + # Parameterised queries (folded from test_params.py) + # ------------------------------------------------------------------ + def test_param_select_int(): + cur.execute("SELECT name, price FROM types_test WHERE id = ?", (1,)) + row = cur.fetchone() + assert row is not None + assert row[0] == "Widget" + assert abs(row[1] - 9.99) < 1e-9 + + run("Param: SELECT by integer", test_param_select_int) + + def test_param_select_string(): + cur.execute("SELECT id, price FROM types_test WHERE name = ?", ("Gadget",)) + row = cur.fetchone() + assert row is not None + assert row[0] == 2 + + run("Param: SELECT by string", test_param_select_string) + + def test_param_no_rows(): + cur.execute("SELECT id FROM types_test WHERE id = ?", (999,)) + assert cur.fetchone() is None + + run("Param: no matching rows", test_param_no_rows) + + def test_param_null_column(): + cur.execute("SELECT quantity FROM types_test WHERE id = ?", (2,)) + row = cur.fetchone() + assert row is not None + assert row[0] is None, f"expected NULL, got {row[0]!r}" + + run("Param: NULL column", test_param_null_column) + + def test_param_multiple_rows(): + cur.execute("SELECT id FROM types_test WHERE active = ? ORDER BY id", (1,)) + ids = [r[0] for r in cur.fetchall()] + assert ids == [1, 3], f"expected [1, 3], got {ids}" + + run("Param: multiple rows", test_param_multiple_rows) + + def test_param_insert(): + cur.execute( + "INSERT INTO types_test (id, name, price, quantity, active) VALUES (?, ?, ?, ?, ?)", + (100, "TestItem", 1.23, 42, 1), + ) + cur.execute("SELECT name, price, quantity FROM types_test WHERE id = ?", (100,)) + row = cur.fetchone() + assert row is not None + assert row[0] == "TestItem" + assert abs(row[1] - 1.23) < 1e-9 + assert row[2] == 42 + cur.execute("DELETE FROM types_test WHERE id = 100") + + run("Param: INSERT + verify", test_param_insert) + + def test_param_reexecute(): + expected = {1: "Widget", 2: "Gadget", 3: "Doohickey"} + for id_, name in expected.items(): + cur.execute("SELECT name FROM types_test WHERE id = ?", (id_,)) + row = cur.fetchone() + assert row is not None, f"no row for id={id_}" + assert row[0] == name, f"id={id_}: expected {name!r}, got {row[0]!r}" + + run("Param: re-execute with different values", test_param_reexecute) + + def test_param_null(): + cur.execute( + "INSERT INTO types_test (id, name, price) VALUES (?, ?, ?)", + (101, "NullPrice", None), + ) + cur.execute("SELECT price FROM types_test WHERE id = ?", (101,)) + row = cur.fetchone() + assert row is not None + assert row[0] is None, f"expected NULL, got {row[0]!r}" + cur.execute("DELETE FROM types_test WHERE id = 101") + + run("Param: NULL binding", test_param_null) + + # ------------------------------------------------------------------ + # Unicode roundtrip + # ------------------------------------------------------------------ + def test_unicode_roundtrip(): + cur.execute("DROP TABLE IF EXISTS unicode_test") + cur.execute("CREATE TABLE unicode_test (id INTEGER PRIMARY KEY, val TEXT)") + values = [ + (1, "日本語"), + (2, "🎉🦀"), + (3, "café résumé"), + (4, "Ünïcödé"), + ] + for row in values: + cur.execute("INSERT INTO unicode_test VALUES (?, ?)", row) + cur.execute("SELECT id, val FROM unicode_test ORDER BY id") + rows = cur.fetchall() + assert len(rows) == len(values), f"expected {len(values)} rows, got {len(rows)}" + for (row_id, row_val), (exp_id, exp_val) in zip(rows, values): + assert row_id == exp_id + assert row_val == exp_val, f"id={exp_id}: expected {exp_val!r}, got {row_val!r}" + cur.execute("DROP TABLE unicode_test") + + run("Unicode roundtrip (Japanese, emoji, accents)", test_unicode_roundtrip) + + # ------------------------------------------------------------------ + # SQLGetData type coercion + # ------------------------------------------------------------------ + def test_getdata_integer_as_char(): + import struct + # Our driver maps SQLite INTEGER columns to SQL_BIGINT (-5). + # Registering an output converter causes pyodbc to skip SQLBindCol and + # instead call SQLGetData(SQL_C_BINARY) for that column, exercising the + # integer→binary coercion path. The converter decodes the 8-byte LE value. + SQL_BIGINT = -5 + received = [] + def decode_bigint(b): + val = struct.unpack(" Path: + """Return the path to the built DLL.""" + rust_target = ( + "x86_64-pc-windows-msvc" if target == "msvc" else "x86_64-pc-windows-gnu" + ) + dll = PROJECT_DIR / "target" / rust_target / "release" / "stackable_odbc_sqlite.dll" + if not dll.exists(): + print(f"ERROR: DLL not found at {dll}", file=sys.stderr) + print("Run without --skip-build, or check your build output.", file=sys.stderr) + sys.exit(1) + return dll + + +def discover_vm_ip(network: str) -> str: + """Get the VM IP from libvirt DHCP leases.""" + print(f"=== Discovering VM IP from network {network} ===") + try: + result = subprocess.run( + ["virsh", "--connect", "qemu:///system", "net-dhcp-leases", network], + capture_output=True, text=True, check=True, + ) + except FileNotFoundError: + print("ERROR: virsh not found. Install libvirt or use --host.", file=sys.stderr) + sys.exit(1) + except subprocess.CalledProcessError as e: + print( + f"ERROR: could not query DHCP leases for network '{network}'.\n" + "Is the VM running? See windows/WINDOWS.md for setup.\n" + f"virsh output: {e.stderr}", + file=sys.stderr, + ) + sys.exit(1) + + # Parse lines like: 2026-04-01 ... ipv4 192.168.197.138/24 sble-addc ... + ips = re.findall(r"ipv4\s+([\d.]+)/", result.stdout) + if not ips: + print( + f"ERROR: no DHCP leases found on network '{network}'.\n" + "Is the VM running? See windows/WINDOWS.md for setup.", + file=sys.stderr, + ) + sys.exit(1) + + ip = ips[-1] # take the most recent lease + print(f"Found VM at {ip}") + return ip + + +class _FileServer(http.server.SimpleHTTPRequestHandler): + """HTTP handler that serves specific files from a lookup table.""" + + file_map: dict[str, Path] = {} + + def do_GET(self): + name = self.path.lstrip("/") + path = self.file_map.get(name) + if path is None or not path.exists(): + self.send_error(404, f"Not found: {name}") + return + data = path.read_bytes() + self.send_response(200) + self.send_header("Content-Length", str(len(data))) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + self.wfile.write(data) + + def log_message(self, format, *args): + pass # silence request logging + + +class http_file_server: + """Context manager that runs a temporary HTTP server in a background thread. + + Serves only the files in ``file_map`` (name → local Path). The server + binds to all interfaces on an available port (or HTTP_PORT if free) and + shuts down when the context exits. + """ + + def __init__(self, file_map: dict[str, Path]): + self.file_map = file_map + self.server = None + self.thread = None + + def __enter__(self) -> int: + handler = type( + "_Handler", + (_FileServer,), + {"file_map": self.file_map}, + ) + port = HTTP_PORT if _port_available(HTTP_PORT) else 0 + self.server = http.server.HTTPServer(("0.0.0.0", port), handler) + if port == 0: + port = self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + return port + + def __exit__(self, *_): + if self.server: + self.server.shutdown() + if self.thread: + self.thread.join(timeout=5) + + +def _port_available(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("0.0.0.0", port)) + return True + except OSError: + return False + + +def run_tests(session, conn_str: str) -> int: + """Run test_integration.py on the VM and return the exit code.""" + r = session.run_ps( + f'& {REMOTE_PYTHON} {REMOTE_TEST} "{conn_str}"' + ) + stdout = r.std_out.decode("utf-8", errors="replace") + print(stdout, end="") + + if r.std_err: + stderr = r.std_err.decode("utf-8", errors="replace") + if "CLIXML" not in stderr: + print(stderr, end="", file=sys.stderr) + + return r.status_code + + +def register_driver(session): + """Register the ODBC driver via registry + odbcconf.exe. + + INSTALLDRIVER alone won't update the DLL path if the driver is already + registered (it only increments UsageCount). Force-update via the registry + to ensure the freshly deployed DLL is always used. + """ + # Must use run_cmd (cmd.exe), not run_ps — PowerShell mangles odbcconf arguments. + cmd = ( + f'odbcconf.exe /A {{INSTALLDRIVER ' + f'"{DRIVER_NAME}|Driver={REMOTE_DLL}|Setup={REMOTE_DLL}|"}}' + ) + r = session.run_cmd("cmd.exe", ["/c", cmd]) + if r.status_code != 0: + print(f"ERROR: driver registration failed: {r.std_err.decode()}", file=sys.stderr) + sys.exit(1) + session.run_ps( + f'Set-ItemProperty ' + f'"HKLM:\\SOFTWARE\\ODBC\\ODBCINST.INI\\{DRIVER_NAME}" ' + f'-Name "Driver" -Value "{REMOTE_DLL}"; ' + f'Set-ItemProperty ' + f'"HKLM:\\SOFTWARE\\ODBC\\ODBCINST.INI\\{DRIVER_NAME}" ' + f'-Name "Setup" -Value "{REMOTE_DLL}"' + ) + print(f"Driver '{DRIVER_NAME}' registered") + + +def register_dsn(session): + """Register a DSN pointing at the test database.""" + cmd = ( + f'odbcconf.exe /A {{CONFIGDSN "{DRIVER_NAME}" ' + f'"DSN={DSN_NAME}|Database={REMOTE_DB}|"}}' + ) + r = session.run_cmd("cmd.exe", ["/c", cmd]) + if r.status_code != 0: + print(f"ERROR: DSN registration failed: {r.std_err.decode()}", file=sys.stderr) + sys.exit(1) + print(f"DSN '{DSN_NAME}' registered") + + +if __name__ == "__main__": + main() From 967722fed70496cd7266d35e93468f28def23dde Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Sun, 26 Jul 2026 18:07:14 +0200 Subject: [PATCH 06/50] chore: move the Windows VM test harness The libvirt VM definition, its Ansible playbook and the unattended-install configuration move unchanged -- none of it is backend-specific. WINDOWS.md documented both drivers and is rewritten to cover only SQLite: the Trino invocations, the four-config Trino matrix, the Trino connection parameters and the Trino driver registration and DSN examples are gone, and the test path drops its test/sqlite/ prefix. openssl_legacy.cnf moves too. It enables OpenSSL's legacy provider for the MD4 that WinRM's NTLM authentication needs, so it is required by this driver's own windows_test.py rather than being TLS configuration for any particular backend. Corrects PROJECT_DIR in test/windows_test.py, which resolved two parents up from the script and now resolves one: the script sat two levels below the workspace root and now sits one below the repository root. It located windows/openssl_legacy.cnf through that path. Co-Authored-By: Claude Opus 5 (1M context) --- test/windows_test.py | 2 +- windows/WINDOWS.md | 288 ++++++++++++++++++ windows/openssl_legacy.cnf | 21 ++ .../windows-install-config/Autounattend.xml | 138 +++++++++ .../windows-install-config/redhat-drivers.crt | 29 ++ windows/vm/inventory.ini | 2 + windows/vm/shell.nix | 22 ++ windows/vm/start.yaml | 148 +++++++++ .../windows-vm-network-internet.xml.j2 | 14 + .../vm/templates/windows-vm-network.xml.j2 | 13 + windows/vm/templates/windows-vm-volume.xml.j2 | 7 + windows/vm/templates/windows-vm.xml.j2 | 99 ++++++ 12 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 windows/WINDOWS.md create mode 100644 windows/openssl_legacy.cnf create mode 100644 windows/vm/files/windows-install-config/Autounattend.xml create mode 100644 windows/vm/files/windows-install-config/redhat-drivers.crt create mode 100644 windows/vm/inventory.ini create mode 100644 windows/vm/shell.nix create mode 100644 windows/vm/start.yaml create mode 100644 windows/vm/templates/windows-vm-network-internet.xml.j2 create mode 100644 windows/vm/templates/windows-vm-network.xml.j2 create mode 100644 windows/vm/templates/windows-vm-volume.xml.j2 create mode 100644 windows/vm/templates/windows-vm.xml.j2 diff --git a/test/windows_test.py b/test/windows_test.py index 375de04..837d212 100644 --- a/test/windows_test.py +++ b/test/windows_test.py @@ -26,7 +26,7 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent -PROJECT_DIR = (SCRIPT_DIR / ".." / "..").resolve() +PROJECT_DIR = (SCRIPT_DIR / "..").resolve() OPENSSL_CNF = PROJECT_DIR / "windows" / "openssl_legacy.cnf" REMOTE_DIR = r"C:\odbc_test" diff --git a/windows/WINDOWS.md b/windows/WINDOWS.md new file mode 100644 index 0000000..2baa1ba --- /dev/null +++ b/windows/WINDOWS.md @@ -0,0 +1,288 @@ +# Windows Testing + +## Quick start: running tests + +Start the VM and its networks first (skip if already running): + +```bash +virsh --connect qemu:///system net-start stackable-odbc-test-hostnet +virsh --connect qemu:///system net-start stackable-odbc-test-internet +virsh --connect qemu:///system start stackable-odbc-test +``` + +Then run from the Linux host (`pywinrm` is installed automatically by `uv`). +This runs the full integration suite twice: DSN-less, then via DSN. + +```bash +uv run --with pywinrm python3 test/windows_test.py +``` + +Common options: + +```bash +# Skip the cargo build (use an already-built DLL) +uv run --with pywinrm python3 test/windows_test.py --skip-build + +# Target a specific VM IP (skip DHCP lease discovery) +uv run --with pywinrm python3 test/windows_test.py --host 192.168.197.138 + +# Non-default libvirt subnet +export ODBC_TEST_HOST_GATEWAY=10.0.0.1 +# or: --gateway 10.0.0.1 + +# Full usage +uv run --with pywinrm python3 test/windows_test.py --help +``` + +### Using a different hypervisor (VirtualBox, Hyper-V, etc.) + +The VM lifecycle section below uses QEMU/KVM via libvirt, and the test script +auto-discovers the VM IP from libvirt DHCP leases. If you are running Windows +in a different hypervisor, the test script still works — just pass the VM's IP +directly with `--host`: + +```bash +uv run --with pywinrm python3 test/windows_test.py --host +``` + +The VM must have WinRM enabled on port 5985 with NTLM auth, and Python 3 + +pyodbc installed. Override credentials with `--user` and `--password` if +they differ from the defaults. + +### OpenSSL legacy provider + +WinRM uses NTLM authentication, which requires MD4 — disabled by default in +modern OpenSSL. The test script automatically sets `OPENSSL_CONF` to point at +`windows/openssl_legacy.cnf`, which enables the legacy provider. + +If you see `unsupported hash type md4` errors, check that the file exists and +that you haven't overridden `OPENSSL_CONF` in your environment. + +## VM lifecycle + +### Prerequisites + +QEMU/KVM and libvirt must be installed and working as system services — +`nix-shell` only provides Ansible and the Python bindings, not the +virtualisation stack itself. Verify with: + +```bash +virsh --connect qemu:///system list --all +``` + +If this fails, install and configure QEMU/KVM + libvirt for your distro. +You will also need a `default` storage pool (`virsh pool-list`) and your +user must be in the `libvirt` group. + +**Note:** QEMU typically runs as a dedicated user (e.g. `libvirt-qemu`) +that cannot read files under your home directory. If the playbook fails +with a permission error on the ISO or virtio drivers, grant read access +with ACLs (e.g. `setfacl -m u:libvirt-qemu:r /path/to/file.iso` and +`setfacl -m u:libvirt-qemu:x` on each parent directory). + +For reference, on Ubuntu 24.04 the following was used to set up these +prerequisites (package names will differ on other distros): + +```bash +sudo apt install -y qemu-system-x86 qemu-utils libvirt-daemon-system \ + libvirt-clients virtinst bridge-utils virt-viewer virt-manager acl +sudo adduser $USER libvirt +sudo adduser $USER kvm +# log out and back in, then verify: +virsh --connect qemu:///system list --all +# uv (Python tool runner, used by the test script): +pipx install uv +``` + +### Creating the VM + +```bash +# Set once — point to your Windows Server 2022 evaluation ISO. +# Download from: https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022 +export WINDOWS_ISO=~/Downloads/SERVER_EVAL_x64FRE_en-us.iso + +cd windows/vm +nix-shell # loads Ansible + libvirt Python bindings +ansible-playbook start.yaml -i inventory.ini +``` + +The playbook creates a QEMU/KVM VM with two networks (host-only + +NAT), boots the Windows ISO, and waits for the guest agent. The +`Autounattend.xml` installs Python 3.12 and pyodbc automatically. + +First run takes ~30 minutes (Windows install + downloads). Use +`virt-viewer` or `virt-manager` to watch progress: + +```bash +virt-viewer --connect qemu:///system stackable-odbc-test +``` + +### Shutting down + +```bash +virsh --connect qemu:///system shutdown stackable-odbc-test +``` + +The VM definition and disk persist — next `start` is fast. + +### Tearing down completely + +Remove the VM, its disk, and the virtual networks: + +```bash +virsh --connect qemu:///system destroy stackable-odbc-test +virsh --connect qemu:///system undefine stackable-odbc-test +virsh --connect qemu:///system vol-delete --pool default stackable-odbc-test.qcow2 + +virsh --connect qemu:///system net-destroy stackable-odbc-test-hostnet +virsh --connect qemu:///system net-destroy stackable-odbc-test-internet +virsh --connect qemu:///system net-undefine stackable-odbc-test-hostnet +virsh --connect qemu:///system net-undefine stackable-odbc-test-internet +``` + +## Reference: driver and DSN management + +### Building the DLL + +The mingw cross-compiler is the simplest option (no extra tooling needed): + +```bash +cargo build --release --target x86_64-pc-windows-gnu +``` + +Output: `target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll` + +Alternatively, MSVC cross-compilation works via `cargo-xwin` (requires +`cargo install cargo-xwin` and nix for LLVM): + +```bash +nix-shell -p llvmPackages_18.clang llvmPackages_18.lld llvmPackages_18.llvm --run \ + "cargo xwin build --release --target x86_64-pc-windows-msvc" +``` + +Output: `target/x86_64-pc-windows-msvc/release/stackable_odbc_sqlite.dll` + +Both produce DLLs that work with the Windows Driver Manager. Prefer mingw for +simplicity; use MSVC if you need to match the target environment exactly. + +### Registering the driver + +All commands below run in `cmd.exe` as Administrator. Adjust the DLL path as +needed. + +```cmd +odbcconf.exe /A {INSTALLDRIVER "stackable_odbc_sqlite|Driver=C:\Users\Administrator\Downloads\stackable_odbc_sqlite.dll|Setup=C:\Users\Administrator\Downloads\stackable_odbc_sqlite.dll|"} +``` + +Both `Driver=` and `Setup=` must point to the same DLL — it exports both the +ODBC API functions and the `ConfigDSNW` setup entry point. + +### Creating a DSN + +The driver's `ConfigDSNW` is headless (no GUI dialog), so DSNs must be created +programmatically rather than through the ODBC Data Source Administrator's "Add" +button: + +```cmd +odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=MySQLite|Database=C:\odbc_test\test.db|"} +``` + +### Connection string parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| Database | Yes | Path to the SQLite database file (e.g. `C:\path\to\test.db`) | + +### Verifying registration + +Open `%SystemRoot%\System32\odbcad32.exe` (64-bit) and confirm: + +- **Drivers tab**: `stackable_odbc_sqlite` is listed +- **User DSN tab**: `MySQLite` (or whatever DSN name you chose) is listed +- Selecting the driver under "Add" should produce no error (but also no dialog — this is expected for a headless driver) + +### Unregistering + +Remove a DSN (User DSN entries are stored under `HKCU`): + +```cmd +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\MySQLite" /f +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "MySQLite" /f +``` + +Remove the driver (via registry, as `odbcconf` does not support `REMOVEDRIVER`): + +```cmd +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\stackable_odbc_sqlite" /f +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "stackable_odbc_sqlite" /f +``` + +## Reference: manual testing + +### PowerShell smoke test + +PowerShell's `System.Data.Odbc` is built into .NET — no extra tools needed. +This example is self-contained: it creates its own table, queries it, and +cleans up. The driver must be registered first (done automatically by the +test script). + +```powershell +$conn = New-Object System.Data.Odbc.OdbcConnection("Driver=stackable_odbc_sqlite;Database=C:\odbc_test\manual_test.db") +$conn.Open() +Write-Host "Connected: $($conn.State)" + +$cmd = $conn.CreateCommand() +$cmd.CommandText = "CREATE TABLE IF NOT EXISTS demo (id INTEGER PRIMARY KEY, name TEXT, value REAL)" +$cmd.ExecuteNonQuery() | Out-Null +$cmd.CommandText = "DELETE FROM demo" +$cmd.ExecuteNonQuery() | Out-Null +$cmd.CommandText = "INSERT INTO demo VALUES (1, 'Alice', 75000.50)" +$cmd.ExecuteNonQuery() | Out-Null +$cmd.CommandText = "INSERT INTO demo VALUES (2, 'Bob', 62000.00)" +$cmd.ExecuteNonQuery() | Out-Null + +$cmd.CommandText = "SELECT * FROM demo" +$reader = $cmd.ExecuteReader() +while ($reader.Read()) { + Write-Host "$($reader[0]) | $($reader[1]) | $($reader[2])" +} +$reader.Close() + +$cmd.CommandText = "DROP TABLE demo" +$cmd.ExecuteNonQuery() | Out-Null +$conn.Close() +Write-Host "Done" +``` + +Expected output: + +```text +Connected: Open +1 | Alice | 75000.5 +2 | Bob | 62000 +Done +``` + +**DSN-based connection:** + +The automated test script registers a DSN named `test_sqlite`. To use it +(in `cmd.exe`, not PowerShell): + +```cmd +odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=MySQLite|Database=C:\odbc_test\manual_test.db|"} +``` + +Then in PowerShell: + +```powershell +$c = New-Object System.Data.Odbc.OdbcConnection("DSN=MySQLite"); $c.Open(); Write-Host "Connected: $($c.State)"; $c.Close() +``` + +### Running test_integration.py manually + +If you need to run the tests without the wrapper script (e.g. from a +PowerShell session on the VM): + +```powershell +& "C:\Program Files\Python312\python.exe" C:\odbc_test\test_integration.py "Driver=stackable_odbc_sqlite;Database=C:\odbc_test\test.db" +``` diff --git a/windows/openssl_legacy.cnf b/windows/openssl_legacy.cnf new file mode 100644 index 0000000..8eefe61 --- /dev/null +++ b/windows/openssl_legacy.cnf @@ -0,0 +1,21 @@ +# OpenSSL configuration that enables the legacy provider. +# Required for WinRM NTLM authentication, which uses MD4 +# (disabled by default in modern OpenSSL). +# +# Usage: OPENSSL_CONF=windows/openssl_legacy.cnf python3 ... +# The test/windows_test.py script sets this automatically. + +openssl_conf = openssl_init + +[openssl_init] +providers = provider_sect + +[provider_sect] +default = default_sect +legacy = legacy_sect + +[default_sect] +activate = 1 + +[legacy_sect] +activate = 1 diff --git a/windows/vm/files/windows-install-config/Autounattend.xml b/windows/vm/files/windows-install-config/Autounattend.xml new file mode 100644 index 0000000..b9d2dd4 --- /dev/null +++ b/windows/vm/files/windows-install-config/Autounattend.xml @@ -0,0 +1,138 @@ + + + + + + + + + E:\ + + + + + + + + + Primary + true + 1 + + + + + 1 + 1 + NTFS + true + C + + + 0 + true + + + + + + + /IMAGE/NAME + Windows Server 2022 SERVERDATACENTER + + + true + + + + + true + + + + + en-US + + en-US + sv-SE + + + + + + + + 1 + certutil -addstore TrustedPublisher A:\redhat-drivers.crt + + + + 2 + reg add HKLM\System\CurrentControlSet\Control\Network\NewNetworkWindowOff /f + + + + + 3 + reg add HKLM\System\CurrentControlSet\Control\TimeZoneInformation /v RealTimeIsUniversal /t REG_DWORD /d 1 /f + + + + + sble-addc + + + + + + + Asdf1234 + true</PlainText> + </AdministratorPassword> + </UserAccounts> + <AutoLogon> + <Enabled>true</Enabled> + <Username>Administrator</Username> + <Password> + <Value>Asdf1234</Value> + <PlainText>true</PlainText> + </Password> + </AutoLogon> + <FirstLogonCommands> + <!-- Install QEMU guest tools --> + <!-- NOTE: MUST happen in OOBE stage since Ansible assumes that having guest tools (more specifically, the qemu guest agent) available means the install is ready to proceed --> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <!-- QEMU guest tools are on virtio-win drive --> + <CommandLine>F:\virtio-win-guest-tools.exe /passive</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Download and install SPICE guest tools (clipboard sync, resolution adjustment) --> + <SynchronousCommand wcm:action="add"> + <Order>2</Order> + <CommandLine>powershell -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://www.spice-space.org/download/windows/spice-guest-tools/spice-guest-tools-0.141/spice-guest-tools-0.141.exe' -OutFile C:\spice-guest-tools.exe; Start-Process C:\spice-guest-tools.exe -ArgumentList '/S' -Wait; Remove-Item C:\spice-guest-tools.exe"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Install Python --> + <SynchronousCommand wcm:action="add"> + <Order>3</Order> + <CommandLine>powershell -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://www.python.org/ftp/python/3.12.9/python-3.12.9-amd64.exe' -OutFile C:\python-installer.exe; Start-Process C:\python-installer.exe -ArgumentList '/quiet InstallAllUsers=1 PrependPath=1' -Wait; Remove-Item C:\python-installer.exe"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Install pyodbc --> + <SynchronousCommand wcm:action="add"> + <Order>4</Order> + <CommandLine>powershell -Command "$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine'); python -m pip install pyodbc"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Signal that all setup is complete (used by start.yaml to wait) --> + <SynchronousCommand wcm:action="add"> + <Order>5</Order> + <CommandLine>powershell -Command "Set-Content -Path C:\setup_complete.txt -Value (Get-Date -Format o)"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + </FirstLogonCommands> + </component> + </settings> + <cpi:offlineImage xmlns:cpi="urn:schemas-microsoft-com:cpi" cpi:source="wim:c:/users/administrator/desktop/install.wim#Windows Server 2022 SERVERDATACENTER"/> +</unattend> diff --git a/windows/vm/files/windows-install-config/redhat-drivers.crt b/windows/vm/files/windows-install-config/redhat-drivers.crt new file mode 100644 index 0000000..14c1faf --- /dev/null +++ b/windows/vm/files/windows-install-config/redhat-drivers.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIFBjCCA+6gAwIBAgIQVsbSZ63gf3LutGA7v4TOpTANBgkqhkiG9w0BAQUFADCB +tDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL +ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTswOQYDVQQLEzJUZXJtcyBvZiB1c2Ug +YXQgaHR0cHM6Ly93d3cudmVyaXNpZ24uY29tL3JwYSAoYykxMDEuMCwGA1UEAxMl +VmVyaVNpZ24gQ2xhc3MgMyBDb2RlIFNpZ25pbmcgMjAxMCBDQTAeFw0xNjAzMTgw +MDAwMDBaFw0xODEyMjkyMzU5NTlaMGgxCzAJBgNVBAYTAlVTMRcwFQYDVQQIEw5O +b3J0aCBDYXJvbGluYTEQMA4GA1UEBxMHUmFsZWlnaDEWMBQGA1UEChQNUmVkIEhh +dCwgSW5jLjEWMBQGA1UEAxQNUmVkIEhhdCwgSW5jLjCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAMA3SYpIcNIEzqqy1PNimjt3bVY1KuIuvDABkx8hKUG6 +rl9WDZ7ibcW6f3cKgr1bKOAeOsMSDu6i/FzB7Csd9u/a/YkASAIIw48q9iD4K6lb +Kvd+26eJCUVyLHcWlzVkqIEFcvCrvaqaU/YlX/antLWyHGbtOtSdN3FfY5pvvTbW +xf8PJBWGO3nV9CVL1DMK3wSn3bRNbkTLttdIUYdgiX+q8QjbM/VyGz7nA9UvGO0n +FWTZRdoiKWI7HA0Wm7TjW3GSxwDgoFb2BZYDDNSlfzQpZmvnKth/fQzNDwumhDw7 +tVicu/Y8E7BLhGwxFEaP0xZtENTpn+1f0TxPxpzL2zMCAwEAAaOCAV0wggFZMAkG +A1UdEwQCMAAwDgYDVR0PAQH/BAQDAgeAMCsGA1UdHwQkMCIwIKAeoByGGmh0dHA6 +Ly9zZi5zeW1jYi5jb20vc2YuY3JsMGEGA1UdIARaMFgwVgYGZ4EMAQQBMEwwIwYI +KwYBBQUHAgEWF2h0dHBzOi8vZC5zeW1jYi5jb20vY3BzMCUGCCsGAQUFBwICMBkM +F2h0dHBzOi8vZC5zeW1jYi5jb20vcnBhMBMGA1UdJQQMMAoGCCsGAQUFBwMDMFcG +CCsGAQUFBwEBBEswSTAfBggrBgEFBQcwAYYTaHR0cDovL3NmLnN5bWNkLmNvbTAm +BggrBgEFBQcwAoYaaHR0cDovL3NmLnN5bWNiLmNvbS9zZi5jcnQwHwYDVR0jBBgw +FoAUz5mp6nsm9EvJjo/X8AUm7+PSp50wHQYDVR0OBBYEFL/39F5yNDVDib3B3Uk3 +I8XJSrxaMA0GCSqGSIb3DQEBBQUAA4IBAQDWtaW0Dar82t1AdSalPEXshygnvh87 +Rce6PnM2/6j/ijo2DqwdlJBNjIOU4kxTFp8jEq8oM5Td48p03eCNsE23xrZl5qim +xguIfHqeiBaLeQmxZavTHPNM667lQWPAfTGXHJb3RTT4siowcmGhxwJ3NGP0gNKC +PHW09x3CdMNCIBfYw07cc6h9+Vm2Ysm9MhqnVhvROj+AahuhvfT9K0MJd3IcEpjX +Z7aMX78Vt9/vrAIUR8EJ54YGgQsF/G9Adzs6fsfEw5Nrk8R0pueRMHRTMSroTe0V +Ae2nvuUU6rVI30q8+UjQCxu/ji1/JnitNkUyOPyC46zL+kfHYSnld8U1 +-----END CERTIFICATE----- diff --git a/windows/vm/inventory.ini b/windows/vm/inventory.ini new file mode 100644 index 0000000..5744fad --- /dev/null +++ b/windows/vm/inventory.ini @@ -0,0 +1,2 @@ +[windows] +sble-addc ansible_connection=community.libvirt.libvirt_qemu ansible_libvirt_uri=qemu:///system ansible_host=stackable-odbc-test ansible_shell_type=powershell diff --git a/windows/vm/shell.nix b/windows/vm/shell.nix new file mode 100644 index 0000000..d2e68a8 --- /dev/null +++ b/windows/vm/shell.nix @@ -0,0 +1,22 @@ +{ pkgs ? import <nixpkgs> { } }: + +let + python = pkgs.python3; + extraAnsibleDeps = pypkgs: [ + pypkgs.libvirt + pypkgs.lxml + ]; +in +pkgs.mkShell rec { + buildInputs = [ ansible ]; + + LC_ALL = "C.UTF-8"; + + ansible = python.pkgs.toPythonApplication + (python.pkgs.ansible-core.overridePythonAttrs (old: { + dependencies = (old.dependencies or []) ++ extraAnsibleDeps python.pkgs; + })); + + ansiblePython = python.withPackages extraAnsibleDeps; + ANSIBLE_PYTHON_INTERPRETER = ansiblePython + "/bin/python"; +} diff --git a/windows/vm/start.yaml b/windows/vm/start.yaml new file mode 100644 index 0000000..6002624 --- /dev/null +++ b/windows/vm/start.yaml @@ -0,0 +1,148 @@ +- name: Create VM and install Windows + hosts: localhost + connection: local + gather_facts: false + vars: + libvirt_uri: qemu:///system + install_iso_windows: "{{ lookup('env', 'WINDOWS_ISO') }}" + + vm_name: stackable-odbc-test + vm_memory_mib: 4096 + vm_vcpus: 8 + vm_disk_name: stackable-odbc-test.qcow2 + vm_disk_pool: default + vm_disk_size_gib: 30 + vm_disk_format: qcow2 + + vm_network_hostnet_name: stackable-odbc-test-hostnet + vm_network_hostnet_subnet: 192.168.197.0/24 + vm_network_internet_name: stackable-odbc-test-internet + vm_network_internet_subnet: 192.168.196.0/24 + + install_iso_virtio_win_url: https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.248-1/virtio-win-0.1.248.iso + install_iso_virtio_win_checksum: sha256:d5b5739cf297f0538d263e30678d5a09bba470a7c6bcbd8dff74e44153f16549 + install_iso_virtio_win: "{{ lookup('first_found', 'target') }}/virtio-win.iso" + + tasks: + - name: Create target folder + ansible.builtin.file: + path: target + state: directory + + - name: Find Windows ISO + ansible.builtin.stat: + path: "{{ install_iso_windows }}" + get_checksum: false + register: install_iso_windows_stat + + - name: Complain about missing Windows ISO + ansible.builtin.fail: + msg: >- + Windows ISO not found. Set the WINDOWS_ISO environment variable to + the path of your Windows Server 2022 evaluation ISO, e.g.: + export WINDOWS_ISO=~/Downloads/SERVER_EVAL_x64FRE_en-us.iso + Download from https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022 + when: install_iso_windows == '' or not install_iso_windows_stat.stat.exists + + - name: Download virtio-win drivers + ansible.builtin.get_url: + dest: "{{ install_iso_virtio_win }}" + url: "{{ install_iso_virtio_win_url }}" + checksum: "{{ install_iso_virtio_win_checksum }}" + + - name: Create VM Network + community.libvirt.virt_net: + name: "{{ vm_network_hostnet_name }}" + command: define + xml: "{{ lookup('template', 'templates/windows-vm-network.xml.j2') }}" + uri: "{{ libvirt_uri }}" + + - name: Start VM Network + community.libvirt.virt_net: + name: "{{ vm_network_hostnet_name }}" + state: active + uri: "{{ libvirt_uri }}" + + - name: Create VM Network (Internet) + community.libvirt.virt_net: + name: "{{ vm_network_internet_name }}" + command: define + xml: "{{ lookup('template', 'templates/windows-vm-network-internet.xml.j2') }}" + uri: "{{ libvirt_uri }}" + + - name: Start VM Network (Internet) + community.libvirt.virt_net: + name: "{{ vm_network_internet_name }}" + state: active + uri: "{{ libvirt_uri }}" + + - name: Create VM + community.libvirt.virt: + command: define + xml: "{{ lookup('template', 'templates/windows-vm.xml.j2') }}" + mutate_flags: + - ADD_UUID + - ADD_MAC_ADDRESSES + uri: "{{ libvirt_uri }}" + + - name: Check if VM Volume already exists + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" vol-info --pool "{{ vm_disk_pool }}" --vol "{{ vm_disk_name }}" + register: result_check_vm_disk + failed_when: false + changed_when: result_check_vm_disk.rc != 0 + + - name: Create VM Volume + when: result_check_vm_disk is changed + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" vol-create --pool "{{ vm_disk_pool }}" --file /dev/stdin + stdin: "{{ lookup('template', 'templates/windows-vm-volume.xml.j2') }}" + + - name: Start VM + community.libvirt.virt: + name: "{{ vm_name }}" + state: running + uri: "{{ libvirt_uri }}" + +- name: Wait for Windows to finish installing + hosts: localhost + connection: local + gather_facts: false + vars: + libvirt_uri: qemu:///system + vm_name: stackable-odbc-test + vm_network_hostnet_name: stackable-odbc-test-hostnet + tasks: + - name: Wait for QEMU guest agent + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" qemu-agent-command "{{ vm_name }}" '{"execute":"guest-ping"}' + register: guest_ping + until: guest_ping.rc == 0 + retries: 120 + delay: 15 + changed_when: false + + - name: Get VM IP from DHCP leases + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" net-dhcp-leases "{{ vm_network_hostnet_name }}" + register: dhcp_leases + changed_when: false + + - name: Extract VM IP + ansible.builtin.set_fact: + vm_ip: "{{ dhcp_leases.stdout | regex_search('ipv4\\s+(\\d+\\.\\d+\\.\\d+\\.\\d+)/', '\\1') | first }}" + + - name: Wait for WinRM + ansible.builtin.wait_for: + host: "{{ vm_ip }}" + port: 5985 + timeout: 1800 + delay: 10 + + - name: VM is ready + ansible.builtin.debug: + msg: >- + Windows VM ready at {{ vm_ip }}. + Note: FirstLogonCommands (Python, pyodbc) may still be running. + The test script waits for setup to complete automatically. + Run tests with: uv run --with pywinrm python3 test/sqlite/windows_test.py diff --git a/windows/vm/templates/windows-vm-network-internet.xml.j2 b/windows/vm/templates/windows-vm-network-internet.xml.j2 new file mode 100644 index 0000000..fa7abfe --- /dev/null +++ b/windows/vm/templates/windows-vm-network-internet.xml.j2 @@ -0,0 +1,14 @@ +<network connections="1"> + <name>{{ vm_network_internet_name }}</name> + <forward mode="nat"/> + <bridge stp='on' delay='0'/> + <ip + address="{{ vm_network_internet_subnet | ansible.utils.ipaddr('next_usable') }}" + netmask="{{ vm_network_internet_subnet | ansible.utils.ipaddr('netmask') }}"> + <dhcp> + <range + start="{{ vm_network_internet_subnet | ansible.utils.next_nth_usable(2) }}" + end="{{ vm_network_internet_subnet | ansible.utils.ipaddr('last_usable') }}"/> + </dhcp> + </ip> +</network> diff --git a/windows/vm/templates/windows-vm-network.xml.j2 b/windows/vm/templates/windows-vm-network.xml.j2 new file mode 100644 index 0000000..687f8bd --- /dev/null +++ b/windows/vm/templates/windows-vm-network.xml.j2 @@ -0,0 +1,13 @@ +<network connections="1"> + <name>{{ vm_network_hostnet_name }}</name> + <forward mode="route"/> + <ip + address="{{ vm_network_hostnet_subnet | ansible.utils.ipaddr('next_usable') }}" + netmask="{{ vm_network_hostnet_subnet | ansible.utils.ipaddr('netmask') }}"> + <dhcp> + <range + start="{{ vm_network_hostnet_subnet | ansible.utils.next_nth_usable(2) }}" + end="{{ vm_network_hostnet_subnet | ansible.utils.ipaddr('last_usable') }}"/> + </dhcp> + </ip> +</network> diff --git a/windows/vm/templates/windows-vm-volume.xml.j2 b/windows/vm/templates/windows-vm-volume.xml.j2 new file mode 100644 index 0000000..6f41a06 --- /dev/null +++ b/windows/vm/templates/windows-vm-volume.xml.j2 @@ -0,0 +1,7 @@ +<volume> + <name>{{ vm_disk_name }}</name> + <capacity unit="GiB">{{ vm_disk_size_gib }}</capacity> + <target> + <format type="{{ vm_disk_format }}"/> + </target> +</volume> diff --git a/windows/vm/templates/windows-vm.xml.j2 b/windows/vm/templates/windows-vm.xml.j2 new file mode 100644 index 0000000..cf49fa7 --- /dev/null +++ b/windows/vm/templates/windows-vm.xml.j2 @@ -0,0 +1,99 @@ +<domain type="kvm"> + <name>{{ vm_name }}</name> + <metadata> + <libosinfo:libosinfo xmlns:libosinfo="http://libosinfo.org/xmlns/libvirt/domain/1.0"> + <libosinfo:os id="http://microsoft.com/win/2k22"/> + </libosinfo:libosinfo> + </metadata> + <memory unit="MiB">{{ vm_memory_mib }}</memory> + <currentMemory unit="MiB">{{ vm_memory_mib }}</currentMemory> + <vcpu placement="static">{{ vm_vcpus }}</vcpu> + <os + {# EFI seems to vary more between distributions, and makes Windows always do the "press any key to install" prompt #} + {# firmware="efi" #}> + <type arch="x86_64" machine="pc-q35-8.0">hvm</type> + </os> + <features> + <acpi/> + <apic/> + <hyperv mode="custom"> + <relaxed state="on"/> + <vapic state="on"/> + <spinlocks state="on" retries="8191"/> + </hyperv> + <vmport state="off"/> + </features> + <cpu mode="host-passthrough" check="none" migratable="on"/> + {# Our unattended install config reconfigures Windows to read UTC time from RTC #} + <clock offset="utc"> + <timer name="rtc" tickpolicy="catchup"/> + <timer name="pit" tickpolicy="delay"/> + <timer name="hpet" present="no"/> + <timer name="hypervclock" present="yes"/> + </clock> + <devices> + <disk type="volume" device="disk"> + <driver name="qemu" type="{{ vm_disk_format }}" discard="unmap"/> + <source pool="{{ vm_disk_pool }}" volume="{{ vm_disk_name }}"/> + <target dev="sda" bus="scsi"/> + <boot order="1"/> + </disk> + <disk type="file" device="cdrom"> + <driver name="qemu" type="raw"/> + <source file="{{ install_iso_windows }}"/> + <target dev="sdb" bus="sata"/> + <readonly/> + <boot order="2"/> + </disk> + <disk type="file" device="cdrom"> + <driver name="qemu" type="raw"/> + <source file="{{ install_iso_virtio_win }}"/> + <target dev="sdc" bus="sata"/> + <readonly/> + </disk> + {# Windows seems to ignore unattended install configs on USB drives #} + <disk type="dir" device="floppy"> + <driver name="qemu" type="fat"/> + <source dir="{{ playbook_dir }}/files/windows-install-config"/> + <target dev="fda"/> + <readonly/> + </disk> + <controller type="scsi" index="0" model="virtio-scsi"/> + <!-- Docker/Kind does not route traffic into libvirt NAT networks properly, so configure a host-only network --> + <interface type="network"> + <source network="{{ vm_network_hostnet_name }}"/> + <model type="virtio"/> + <alias name="ua-net-hostnet"/> + </interface> + <!-- Routed networks require extra configuration to provide internet access, so provide a NATed secondary network interface instead --> + <interface type="network"> + <source network="{{ vm_network_internet_name }}"/> + <model type="virtio"/> + <alias name="ua-net-internet"/> + </interface> + <serial type="pty"/> + <console type="pty"> + <target type="serial" port="0"/> + </console> + <channel type="spicevmc"> + <target type="virtio" name="com.redhat.spice.0"/> + </channel> + <channel type="unix"> + <target type="virtio" name="org.qemu.guest_agent.0"/> + </channel> + <input type="tablet" bus="usb"/> + <input type="mouse" bus="ps2"/> + <input type="keyboard" bus="ps2"/> + <graphics type="spice" autoport="yes"> + <listen type="address"/> + <image compression="off"/> + </graphics> + <video> + <model type="qxl" ram="65536" vram="65536" vgamem="16384" heads="1" primary="yes"/> + </video> + <redirdev bus="usb" type="spicevmc"/> + <redirdev bus="usb" type="spicevmc"/> + <watchdog model="itco" action="reset"/> + <memballoon model="virtio"/> + </devices> +</domain> From 787cfffb4991ff2cbe5274910abe9c0733604424 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:09:40 +0200 Subject: [PATCH 07/50] ci: add build, lint, audit and release workflows Carried over from the workspace and de-workspaced: cargo test replaces cargo test --workspace, the -p selectors and crates/ prefixes are gone, and the release tag trigger drops the sqlite- prefix that disambiguated two drivers releasing from one repository. The SQLite integration suite runs in CI. The sibling Trino repository dropped its equivalent because a Dockerised Trino and Postgres exceed a standard runner; this suite needs only unixODBC and the sqlite3 CLI. The Miri job is not carried over. It only ever ran against stackable-odbc-core -- the driver crates link C libraries Miri cannot execute -- and belongs to that repository. These workflows fail until stackable-odbc-core is reachable to the runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/build.yaml | 132 ++++++++++++++++++++ .github/workflows/pr_pre-commit.yaml | 46 +++++++ .github/workflows/release.yaml | 171 ++++++++++++++++++++++++++ .github/workflows/security_audit.yaml | 24 ++++ 4 files changed, 373 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/pr_pre-commit.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/security_audit.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..5515a40 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,132 @@ +--- +name: Build and Test + +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + merge_group: + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN_VERSION: "1.95.0" + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + # odbc-sys links against libodbc/libodbcinst, so the unixODBC dev + # libraries must be present to link the test binaries (no running Driver + # Manager is needed — only the libraries). + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + version: ubuntu-latest + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + - name: Run unit tests + run: cargo test + + # Unlike the sibling Trino driver, whose integration suite needs a + # Dockerised coordinator and Postgres, this one needs only unixODBC and the + # sqlite3 CLI and runs comfortably on a standard runner. + sqlite-integration: + name: SQLite Integration Tests + runs-on: ubuntu-latest + needs: [unit-tests] + steps: + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev unixodbc sqlite3 + version: ubuntu-latest + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + + - name: Run SQLite integration tests + run: | + ./test/setup.sh + ./test/run-tests.sh + + windows-cross-compile: + name: Cross-compile Windows DLL + runs-on: ubuntu-latest + needs: [unit-tests] + steps: + - name: Install MinGW cross-compiler + run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + targets: x86_64-pc-windows-gnu + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: windows-gnu + + - name: Build Windows DLL + run: cargo build --target x86_64-pc-windows-gnu --release + + - name: Verify DLL exports + run: | + x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll | grep -c "SQL" | xargs -I{} echo "SQLite DLL: {} ODBC symbols exported" + + # Single required check for branch protection rules. + finished: + name: Finished Build and Test + if: always() + needs: + - unit-tests + - sqlite-integration + - windows-cross-compile + runs-on: ubuntu-latest + steps: + - name: Check job results + run: | + if [[ "${{ needs.unit-tests.result }}" != "success" ]] || + [[ "${{ needs.sqlite-integration.result }}" != "success" ]] || + [[ "${{ needs.windows-cross-compile.result }}" != "success" ]]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All jobs passed" diff --git a/.github/workflows/pr_pre-commit.yaml b/.github/workflows/pr_pre-commit.yaml new file mode 100644 index 0000000..80289ad --- /dev/null +++ b/.github/workflows/pr_pre-commit.yaml @@ -0,0 +1,46 @@ +--- +name: pre-commit + +on: + pull_request: + merge_group: + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN_VERSION: "1.95.0" + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + # The cargo-test pre-commit hook links libodbc via odbc-sys. + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + version: ubuntu-latest + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + components: rustfmt, clippy + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + - name: Install cargo-deny and cargo-sort + uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4 + with: + tool: cargo-deny,cargo-sort + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..3377c86 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,171 @@ +--- +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN_VERSION: "1.95.0" + +jobs: + verify-version: + name: Verify tag matches Cargo.toml + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract.outputs.version }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - id: extract + name: Compare tag and Cargo.toml version + run: | + TAG="${GITHUB_REF#refs/tags/}" + TAG_VERSION="${TAG#v}" + CARGO_VERSION=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/version = "(.+)"/\1/') + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag $TAG says version $TAG_VERSION but Cargo.toml has $CARGO_VERSION" + exit 1 + fi + echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" + echo "Verified: releasing stackable-odbc-sqlite $CARGO_VERSION" + + integration-test: + name: SQLite Integration Tests + runs-on: ubuntu-latest + needs: [verify-version] + steps: + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev unixodbc sqlite3 + version: ubuntu-latest + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + + - name: Run SQLite integration tests + run: | + ./test/setup.sh + ./test/run-tests.sh + + build-and-package: + name: Build and package SQLite release archives + runs-on: ubuntu-latest + needs: [verify-version, integration-test] + steps: + - name: Install host dependencies + run: | + sudo apt-get update + sudo apt-get install -y unixodbc-dev gcc-mingw-w64-x86-64 zip + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + targets: x86_64-pc-windows-gnu + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: release-sqlite + + - name: Build Linux release binary + run: cargo build --release + + - name: Build Windows release binary (cross) + run: cargo build --release --target x86_64-pc-windows-gnu + + - name: Assemble release archives + env: + VERSION: ${{ needs.verify-version.outputs.version }} + run: ./packaging/build-archives.sh + + - name: Sanity-check archive contents + env: + VERSION: ${{ needs.verify-version.outputs.version }} + run: | + DIST=packaging/dist + LINUX="$DIST/stackable-odbc-sqlite-${VERSION}-linux-x64.tar.gz" + WINDOWS="$DIST/stackable-odbc-sqlite-${VERSION}-windows-x64.zip" + + echo "--- Linux archive ---" + tar -tzf "$LINUX" + for f in libstackable_odbc_sqlite.so install.sh uninstall.sh README.md LICENSE; do + tar -tzf "$LINUX" | grep -qx "./$f" || { echo "::error::missing $f in linux archive"; exit 1; } + done + + echo "--- Windows archive ---" + unzip -l "$WINDOWS" + for f in stackable_odbc_sqlite.dll install.bat uninstall.bat README.md LICENSE; do + unzip -l "$WINDOWS" | grep -q " $f\$" || { echo "::error::missing $f in windows archive"; exit 1; } + done + + echo "Archive sanity check passed." + + - name: Upload archives as workflow artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sqlite-release-archives + path: packaging/dist/* + retention-days: 7 + + publish-release: + name: Publish GitHub Release + runs-on: ubuntu-latest + needs: [verify-version, build-and-package] + steps: + - name: Download archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: sqlite-release-archives + path: dist + + - name: Determine prerelease flag + id: prerelease + env: + VERSION: ${{ needs.verify-version.outputs.version }} + run: | + if [[ "$VERSION" == *-* ]]; then + echo "flag=true" >> "$GITHUB_OUTPUT" + else + echo "flag=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: ${{ github.ref_name }} + name: SQLite driver ${{ needs.verify-version.outputs.version }} + generate_release_notes: true + prerelease: ${{ steps.prerelease.outputs.flag }} + files: | + dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-linux-x64.tar.gz + dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-windows-x64.zip diff --git a/.github/workflows/security_audit.yaml b/.github/workflows/security_audit.yaml new file mode 100644 index 0000000..3f04918 --- /dev/null +++ b/.github/workflows/security_audit.yaml @@ -0,0 +1,24 @@ +--- +name: Daily Security Audit + +on: + schedule: + # Run every day at 04:15 UTC: https://crontab.guru/#15_4_*_*_* + - cron: '15 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} From bec6a3cc8f8bb80adf54743e6641280619da082f Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:15:02 +0200 Subject: [PATCH 08/50] docs: add README, agent guides and changelog The README is rewritten to stand alone: it no longer links a sibling crate by relative path, no longer positions the driver against another driver, and points at this repository for support. AGENTS.md is rewritten for this crate alone. The workspace guide covered all three crates; adding an ODBC function, adding a driver, Miri and fuzzing all belong to stackable-odbc-core. New sections record why both cursor-behaviour hooks return Preserve, that eager materialisation is load-bearing rather than an implementation detail, and that this crate deliberately takes no direct odbc-sys dependency. CHANGELOG.md is new, following Keep a Changelog, and records the extraction along with the corrected cursor-behaviour and SQLCloseCursor values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 37 +++++++ CLAUDE.md | 57 +++++++++++ README.md | 77 ++++++++++++++ 4 files changed, 456 insertions(+) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9cdc0d4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,285 @@ +# Agent Guide + +Implementation details for AI agents working on `stackable-odbc-sqlite`. + +This crate is an ODBC driver for [SQLite](https://sqlite.org). It contains +**only** SQLite-specific code: the `Backend` and `StatementBackend` +implementations, connection-string parsing, SQLite-to-ODBC type conversion, ODBC +escape-sequence translation, and the catalog and metadata functions. Everything +generic — handle management, UTF-16 marshalling, diagnostics, panic safety, and +the 73 C ABI entry points — lives in +[`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core). + +## Quick Reference + +| Topic | When to Read | +|-------|-------------| +| [Relationship to core](#relationship-to-stackable-odbc-core) | Deciding where a change belongs | +| [Conventions](#conventions) | Any code change | +| [Backend error mapping](#backend-error-mapping) | Touching an error path | +| [Transactions](#transactions) | Touching `SQLEndTran`, autocommit or cursor behaviour | +| [Architecture](#architecture-of-this-crate) | Understanding the module layout | +| [Connection string keys](#connection-string-keys) | Adding or changing a parameter | +| [Testing](#testing) | Writing or running tests | +| [Packaging](#packaging) | Cutting a release | + +```bash +cargo build # needs unixodbc-dev +cargo test # unit + FFI tests; needs no server +cargo clippy --all-targets -- -D warnings +pre-commit run --all-files # the gate; run before every commit + +./test/setup.sh # build driver, create test.db, write ODBC config +./test/run-tests.sh # run the integration suite +``` + +## Relationship to stackable-odbc-core + +`stackable-odbc-core` is a path dependency on a sibling checkout until it is +published: + +```toml +stackable-odbc-core = { path = "../stackable-odbc-core" } +``` + +There is a matching `TODO` in `Cargo.toml`. Until it is resolved, CI cannot pass +— a path dependency does not resolve on a runner. This crate is not published to +crates.io; releases are GitHub Release archives built by +`.github/workflows/release.yaml`. + +| Concern | Owner | +|---------|-------| +| Handle allocation, tag validation, `panic_safe` | core | +| UTF-16 marshalling, diagnostics, `SQLGetDiagRec` | core | +| The 73 exported C ABI entry points (`forward_ffi!`) | core | +| Generic `SQLGetInfo` defaults, cursor-state tracking | core | +| `Backend` / `StatementBackend` trait definitions | core | +| Opening the database, executing, fetching | this crate | +| SQLite storage class → SQL type mapping, value conversion | this crate | +| Catalog and metadata queries | this crate | +| Connection-string parsing | this crate | +| ODBC escape-sequence translation | this crate | + +`src/lib.rs` is the whole export surface: + +```rust +stackable_odbc_core::forward_ffi!(crate::backend::SqliteBackend); +``` + +That one line expands to every `#[unsafe(no_mangle)] pub unsafe extern "system"` +entry point. If a new ODBC function needs to be exported, it is added to core's +`forward_ffi!` macro, not here; this crate only implements whatever new trait +method it calls. + +## Conventions + +### Changelog + +Every change an application can observe — a reported `SQLGetInfo` value, a +SQLSTATE, a type mapping — gets an entry in `CHANGELOG.md` under +`## [Unreleased]`, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Internal refactoring does not. + +### Logging in backend methods + +Use `tracing` macros, never `println!`. The FFI entry points in core already log +their own arguments and return codes, so a backend method should log what core +cannot see: the SQL it is about to run, the SQLite error it just mapped, the +number of rows it materialised. `ODBC_LOG_LEVEL` / `ODBC_LOG_FILE` control +output; both are initialised by core. + +### Named constants + +ODBC attribute values, function IDs, and bitmap constants must use named `const` +definitions. Never write raw integer literals for ODBC-spec-defined values. Name +them after the ODBC spec name (e.g. `SQL_AUTOCOMMIT_ON`, `SQL_CB_PRESERVE`). + +**This applies to tests too.** Test code is where raw literals creep back in +most easily, usually with the spec name relegated to a trailing comment. A +comment is not a constant: + +```rust +// BAD — the value is unchecked and the name is only a comment +sql_bind_parameter::<B>(stmt, 1, 1 /* SQL_PARAM_INPUT */, ..., -5 /* SQL_BIGINT */, ...); + +// GOOD — the compiler validates both +sql_bind_parameter::<B>(stmt, 1, ParamType::Input as i16, ..., SqlDataType::EXT_BIG_INT.0, ...); +``` + +Prefer the `odbc-sys` type over defining a new constant when one exists — most +spec values are already modelled: + +| Value | Use | +|-------|-----| +| `SQL_PARAM_INPUT`, `SQL_PARAM_OUTPUT`, … | `ParamType::Input as i16` | +| `SQL_BIGINT`, `SQL_VARCHAR`, `SQL_INTEGER`, … | `SqlDataType::EXT_BIG_INT.0` (note the `.0`) | +| `SQL_C_SBIGINT`, `SQL_C_WCHAR`, … | `CDataType::SBigInt as i16` | +| `SQL_ATTR_*` | `StatementAttribute::*` / `ConnectionAttribute::*` | +| `SQL_HANDLE_*` | `HandleType::*` | + +All are re-exported from `stackable_odbc_core::types`. **This crate takes no +direct `odbc-sys` dependency** — it reaches those types only through core's +re-exports. That is deliberate: `src/ffi_integration_tests.rs` defines a local +`RawTimestamp` mirroring `SQL_TIMESTAMP_STRUCT` rather than pull the crate in. +Do not add `odbc-sys` to `Cargo.toml`. + +### Type cast safety + +Never `as`-cast a value that can exceed the target type. Use `try_into()` and +map the failure to a SQLSTATE, or clamp deliberately with a comment saying why +the clamp is correct. Row counts, column sizes and buffer lengths all cross +between `usize`, `i64`, `u16` and `i16` in this crate. + +### Backend error mapping + +**Route every `rusqlite` error through `map_sqlite_error`** (`src/backend.rs`). +Never hand-build a `SqliteError` or `OdbcError` from a `rusqlite::Error` at the +call site; that function is the single place that decides the SQLSTATE. + +Convert raw integers to typed enums at the boundary with the `xxx_from_raw()` +functions from core — never `transmute`. + +### 08001 versus 08S01 + +`08001` ("client unable to establish connection") is only valid from the +connection functions. Once a connection exists, a failing link is `08S01` +("communication link failure") — that is the code the diagnostics tables of +`SQLExecute`, `SQLFetch`, `SQLGetInfo` and the rest actually list. + +For this driver `connect` is where real I/O happens: +`rusqlite::Connection::open` touches the filesystem, so a missing or unreadable +database file is `08001`. Failures after that point are `08S01`. + +### Transactions + +SQLite supports transactions and this driver reports `SQL_TC_DML` for +`SQL_TXN_CAPABLE`, so manual-commit mode is honoured for real: +`set_autocommit(false)` issues `BEGIN`, and `end_tran` issues `COMMIT` or +`ROLLBACK` and then opens the next transaction while still in manual-commit +mode. + +Both `cursor_commit_behavior` and `cursor_rollback_behavior` return +`CursorBehavior::Preserve`, and **this depends on an implementation detail**: +`execute::exec_direct` materialises every result set eagerly, so no +`rusqlite::Statement` is live when `end_tran` runs. Raw SQLite is stricter — a +ROLLBACK aborts pending statements with `SQLITE_ABORT` (>= 3.7.11), which would +be `SQL_CB_CLOSE`, and a COMMIT with pending writes fails with `SQLITE_BUSY`. + +If result sets ever become lazily streamed, both hooks must be revisited, and +`SQL_CB_CLOSE` would additionally require a real +`StatementBackend::close_cursor`. `end_tran_cursor_behaviour_is_preserve_for_commit_and_rollback` +pins the reported values through the FFI entry point. + +## Architecture of this crate + +| Path | Responsibility | +|------|----------------| +| `src/lib.rs` | The `forward_ffi!` invocation and the crate docs | +| `src/backend.rs` | `SqliteBackend`, `SqliteConnection`, `SqliteStatement`, `SqliteError`, `map_sqlite_error` | +| `src/backend/execute.rs` | `exec_direct`, `prepare`, `execute`, and the `StatementBackend` impl | +| `src/backend/info.rs` | `SQLGetInfo` answers and the capability bitmaps, plus the snapshot test | +| `src/backend/metadata.rs` | The catalog functions: tables, columns, primary keys, statistics, special columns | +| `src/backend/params.rs` | Parameter binding | +| `src/backend/types/connect_params.rs` | `SqliteConnectParams` | +| `src/escape_dialect.rs` | ODBC escape-sequence translation for SQLite's dialect | +| `src/type_conversion.rs` | SQLite storage classes and declared types → ODBC SQL types | +| `src/ffi_integration_tests.rs` | Tests that drive the real C ABI entry points | + +### Result sets are materialised eagerly + +`SqliteStatement` holds `rows: Vec<Vec<ColumnValue>>` and `cursor: i64` — an +index into an in-memory snapshot, not a live SQLite cursor. `exec_direct` +collects every row before returning and the `rusqlite::Statement` is finalized +at that point. + +This is load-bearing well beyond memory use. It is why the cursor-behaviour +hooks report `Preserve`, why `SQLEndTran` cannot disturb a cursor, and why +concurrency is a non-issue. Changing it is not a local optimisation. + +## Connection string keys + +Keys are matched case-insensitively and stored lowercase by core's +`ConnectParams`. + +| Key | Required | Description | +|-----|----------|-------------| +| `Database` | Yes | Path to the database file, or `:memory:` | + +Adding a key means adding a `PARAM_*` constant in +`src/backend/types/connect_params.rs`, reading it in the `TryFrom` impl, and +listing it in `Backend::browse_connect_attrs` if `SQLBrowseConnect` should +prompt for it. + +## Testing + +### Unit and FFI tests + +```bash +cargo test +``` + +Needs no database file — the FFI tests connect to `:memory:`. `cargo test` runs +both the per-module unit tests and `src/ffi_integration_tests.rs`, which drives +the real exported entry points against real handles. Prefer adding to the FFI +tests when the behaviour is observable by an application: they catch the +marshalling and cursor-state bugs that unit tests on the backend cannot. + +### Integration tests + +```bash +./test/setup.sh # build, create test/test.db, write odbc.ini/odbcinst.ini +./test/run-tests.sh # pyodbc suite through real unixODBC +./test/run-tests.sh --windows # also run the Windows VM suite +``` + +`test/setup.sh` and `test/run-tests.sh` regenerate `test/odbc.ini`, +`test/odbcinst.ini` and `test/test.db`; all three are gitignored because they +hold absolute paths. + +### Windows VM tests + +See [windows/WINDOWS.md](windows/WINDOWS.md). Requires a provisioned libvirt VM; +`test/windows_test.py` runs the same pyodbc suite over WinRM, DSN-less and then +via DSN. + +### Benchmarks + +```bash +cargo bench +``` + +`benches/fetch_sqlite.rs` drives the full FFI fetch path against `:memory:` and +exists to catch regressions in the eager-materialise and per-call clone costs of +the `SqliteBackend` → `ColumnValue` → `write_column_value` pipeline. + +### What runs in core, not here + +Do not reintroduce these — they moved with the framework: + +- **Miri.** The driver crates link C libraries (bundled SQLite) that Miri cannot + execute. Core is pure Rust and holds the raw-pointer marshalling. +- **Fuzzing.** The `utf16` and `column_value` fuzz targets fuzz core's code. +- **Generic FFI entry-point tests.** Handle tags, panic safety and diagnostics + are core's. + +## Packaging + +`packaging/build-archives.sh` assembles the Linux and Windows release archives +from binaries already built by `cargo build --release`; see +[packaging/README.md](packaging/README.md). + +### Cutting a release + +`release.toml` configures `cargo-release`. It bumps the version, rewrites +`CHANGELOG.md` and `packaging/README.md`, commits, tags and pushes; the `v*` tag +triggers `.github/workflows/release.yaml`, which builds both binaries and +publishes the GitHub Release. + +```bash +release/release.sh patch # dry run +release/release.sh patch --execute # for real, from main only +``` + +`publish = false`: this crate is not published to crates.io. Tags and commits +are signed by configuration, so a release cut without a signing key fails +loudly rather than producing an unsigned tag. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a26e606 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial extraction of `stackable-odbc-sqlite` into its own repository, from + the `stackable-odbc-rs` workspace it was developed in. Provides the ODBC + driver for SQLite: the `Backend` and `StatementBackend` implementations, + connection-string parsing, SQLite-to-ODBC type conversion, ODBC + escape-sequence translation, and the catalog and metadata functions, with the + C ABI entry points generated by `stackable-odbc-core`'s `forward_ffi!` macro. + +### Changed + +- `SQL_CURSOR_COMMIT_BEHAVIOR` now reports `SQL_CB_PRESERVE` instead of + `SQL_CB_DELETE`, and `SQL_CURSOR_ROLLBACK_BEHAVIOR` is now declared rather + than left to a fallback. Both report `SQL_CB_PRESERVE`. The driver + materialises every result set eagerly, so no SQLite statement is live when + `SQLEndTran` runs and neither commit nor rollback can disturb an open cursor. + The previous `SQL_CB_DELETE` was never accurate: `stackable-odbc-core` + advertised it and implemented nothing, so the driver reported that it + destroyed cursors on commit while in fact preserving them. + +### Fixed + +- `SQLCloseCursor` after a statement that produced no result set now returns + `24000` rather than succeeding. An `INSERT` opens no cursor, so there is + nothing to close; the call was accepted because cursor state was inferred + from whether a backend statement existed. + +[Unreleased]: https://github.com/stackabletech/stackable-odbc-sqlite/commits/HEAD diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..73a09f9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# Project Rules + +Read and follow @AGENTS.md — it contains architecture, patterns, and procedures. + +## Non-Negotiable Rules + +- **ODBC spec compliance is mandatory.** Read the spec page for every function + whose behaviour you change. The generic FFI entry points live in + `stackable-odbc-core`, but what this driver returns from `get_info`, + `get_info_raw`, the catalog functions and the type-conversion paths is + directly observable by applications, and each has a spec-defined shape and + value range. Never claim a SQLSTATE or an info value is wrong without checking + the actual spec table first. Pay attention to **(DM)** annotations — those + SQLSTATEs are returned by the Driver Manager, not the driver. +- **Route every client error through `map_sqlite_error`.** Never hand-build an + `OdbcError` or `SqliteError` from a `rusqlite::Error` at the call site; that + function is the single place that decides the SQLSTATE. +- **Use `odbc-sys` types** — never redefine enums, structs, or constants it + already provides. They are re-exported from `stackable_odbc_core::types`. Do + **not** add `odbc-sys` as a direct dependency: this crate deliberately reaches + those types only through core's re-exports. +- **Convert raw integers to typed enums at the boundary** — use the + `xxx_from_raw()` functions from core, never `transmute`. +- **Do not make result-set fetching lazy.** `exec_direct` materialises every row + before returning, and two reported ODBC capabilities + (`SQL_CURSOR_COMMIT_BEHAVIOR`, `SQL_CURSOR_ROLLBACK_BEHAVIOR`) are only + correct because of it. See the Transactions section of AGENTS.md. +- **Run `pre-commit run --all-files`** before every commit. This is the single + source of truth for what must pass. + +## Scope + +- Do not modify files outside the scope of the current task. +- Do not add features, refactoring, or "improvements" beyond what was asked. +- If unsure whether something is in scope, ask. + +## Data Retrieval + +Never read entire files by default. Survey, locate, then extract. + +1. **Survey first** — check file size before reading (`stat -c%s file`). Files + >50 KB must be sliced, not read whole. `src/ffi_integration_tests.rs` (~4600 + lines), `src/backend/metadata.rs` (~1700), `src/backend/info.rs` (~1600) and + `src/type_conversion.rs` (~1000) are all well over that. +2. **Navigate definitions with ctags** — run `ctags -R .` once to build a tags + index, then `grep "^SymbolName" tags` to find the exact file and line of any + function, struct, or trait — no file reading needed. +3. **Locate with Grep** — find patterns, keywords, or usages before reading. Use + `-C` for context lines. +4. **Extract with Read (offset + limit)** — once you know the line range, read + only that slice. +5. **Structured data** — use `jq` for JSON, `yq` for YAML; never read raw markup + whole. +6. **Filesystem survey** — use `tree -L 2 -I '.git|target|node_modules'` instead + of recursive `ls`. +7. **Verify edits with diff** — after editing, `git diff -u` to confirm changes + instead of re-reading. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c4b51b --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# stackable-odbc-sqlite + +ODBC 3.x driver for [SQLite](https://sqlite.org), built on the +[stackable-odbc-core](https://github.com/stackabletech/stackable-odbc-core) +framework. + +The driver compiles to a C dynamic library that an ODBC Driver Manager +(unixODBC on Linux, the built-in Driver Manager on Windows) loads at runtime. +It opens a local SQLite database file through `rusqlite` with the bundled +SQLite library, so it needs no server and no external SQLite installation. + +## Requirements + +- Rust 1.95.0+ (pinned in `rust-toolchain.toml`) +- Linux: `unixODBC` and `isql` (`pacman -S unixodbc-dev` / `apt install unixodbc-dev`) +- `sqlite3` CLI for creating the test database (`pacman -S sqlite` / `apt install sqlite3`) + +## Building + +```bash +cargo build +``` + +Linux output: `target/debug/libstackable_odbc_sqlite.so`. + +## Connection string parameters + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| Database | Yes | -- | Path to the SQLite database file (`:memory:` for an in-memory database) | + +## Testing + +Run all commands from the repository root. + +```bash +# Build the driver, create the test database, write the ODBC config +./test/setup.sh + +# Connect interactively +export ODBCSYSINI=$(pwd)/test +export ODBCINI=$(pwd)/test/odbc.ini +isql -3 test_sqlite -v +``` + +Or with a DSN-less connection string: + +```bash +isql -3 -k "Driver=$(pwd)/target/debug/libstackable_odbc_sqlite.so;Database=$(pwd)/test/test.db" -v +``` + +The test database (`test/test.db`) has a `types_test` table with integer, text, +real, boolean, blob, and text-based datetime columns (see +`test/create_test_db.sql`). The full integration suite runs via +`./test/run-tests.sh` (add `--windows` for the VM suite); see +[AGENTS.md](AGENTS.md#testing) for the complete matrix. + +### Logging + +```bash +# Log to stderr at debug level +ODBC_LOG_LEVEL=debug isql -3 test_sqlite -v + +# Log to a file (levels: trace, debug, info, warn, error) +ODBC_LOG_LEVEL=debug ODBC_LOG_FILE=/tmp/odbc.log isql -3 test_sqlite -v +``` + +This is invaluable for seeing which ODBC functions are called, and in what order. + +## Releasing + +See [packaging/README.md](packaging/README.md) for building release archives, +and `release.toml` for the `cargo-release` configuration. + +## License + +Apache-2.0 From a57edd311f4bfa490a321236e5cb6947bfd2da83 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:16:56 +0200 Subject: [PATCH 09/50] chore: add cargo-release configuration Bumps the version, rewrites CHANGELOG.md and packaging/README.md, then commits, tags and pushes. Publication to crates.io is disabled: the tag triggers the release workflow, which builds both binaries and publishes a GitHub Release with the archives. Tags and commits are signed by configuration rather than by the releaser's git settings, so an unsigned tag fails loudly instead of being produced silently, and allow-branch keeps a stray --execute from tagging whichever branch happens to be checked out. Corrects the release.sh header comment, which named README.md as a file a release rewrites; release.toml rewrites packaging/README.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- release.toml | 75 ++++++++++++++++++++++++++++++++++++++++++++++ release/release.sh | 23 ++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 release.toml create mode 100755 release/release.sh diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..218fcc2 --- /dev/null +++ b/release.toml @@ -0,0 +1,75 @@ +# cargo-release configuration for stackable-odbc-sqlite. +# +# cargo-release is dry-run by default; --execute is required to mutate state. +# Publication to crates.io is deliberately disabled: this configuration only +# bumps the version, rewrites CHANGELOG.md and packaging/README.md, commits, +# tags and pushes. The v{version} tag then triggers +# .github/workflows/release.yaml, which builds both binaries, assembles the +# release archives and publishes the GitHub Release. + +publish = false +push = true +# Signing is requested here rather than left to the releaser's `tag.gpgsign` / +# `commit.gpgsign`, so a release tag is signed regardless of whose machine it +# is cut on — and fails loudly instead of silently producing an unsigned tag +# when no signing key is configured. +sign-tag = true +sign-commit = true +consolidate-commits = false + +# Day-to-day work happens on feature branches. Without this, a stray +# `--execute` would tag whichever branch happened to be checked out. +allow-branch = ["main"] + +# No git hooks are installed in .git/hooks, so pre-commit does not run on the +# commit cargo-release makes. Run it explicitly instead. This executes against +# the pre-bump tree, so it validates code rather than version strings. +pre-release-hook = ["pre-commit", "run", "--all-files"] + +# Both follow the conventional-commit style used throughout this repo's +# history, rather than cargo-release's defaults. +pre-release-commit-message = "chore(release): version {{version}}" +tag-message = "chore(release): version {{version}}" + +[[pre-release-replacements]] +file = "CHANGELOG.md" +search = "## \\[Unreleased\\]" +replace = "## [Unreleased]\n\n## [{{version}}] — {{date}}" +exactly = 1 + +# The next two rules maintain the link-reference footer. Exactly one of them +# applies to any given release, and the order matters: replacements run +# sequentially, so the compare-form rule must come first. Reversed, the +# `commits/HEAD` rule would write a `compare/...` link that the compare-form +# rule then matched in the same pass, emitting the tag link twice. + +# Subsequent-release case: rewrite the `[Unreleased]` compare link to point at +# the new version, and prepend a `[{version}]` tag link. With `min = 0`, this +# rule is a silent no-op on the first release, when the footer still holds the +# `commits/HEAD` placeholder; after that it matches on every release. +[[pre-release-replacements]] +file = "CHANGELOG.md" +search = "\\[Unreleased\\]: https://github.com/stackabletech/stackable-odbc-sqlite/compare/v[0-9]+\\.[0-9]+\\.[0-9]+\\.\\.\\.HEAD" +replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-sqlite/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-sqlite/releases/tag/v{{version}}" +min = 0 + +# First-release case: the initial placeholder points at `commits/HEAD`. +# Fires exactly once — on the first release — after which the line is in the +# `compare/...` form the rule above owns, and this one never matches again. +[[pre-release-replacements]] +file = "CHANGELOG.md" +search = "\\[Unreleased\\]: https://github.com/stackabletech/stackable-odbc-sqlite/commits/HEAD" +replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-sqlite/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-sqlite/releases/tag/v{{version}}" +min = 0 + +[[pre-release-replacements]] +file = "packaging/README.md" +search = "VERSION=[0-9]+\\.[0-9]+\\.[0-9]+" +replace = "VERSION={{version}}" +exactly = 1 + +[[pre-release-replacements]] +file = "packaging/README.md" +search = "stackable-odbc-sqlite-[0-9]+\\.[0-9]+\\.[0-9]+-linux-x64\\.tar\\.gz" +replace = "stackable-odbc-sqlite-{{version}}-linux-x64.tar.gz" +exactly = 1 diff --git a/release/release.sh b/release/release.sh new file mode 100755 index 0000000..ac9f3ef --- /dev/null +++ b/release/release.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# release.sh — convenience wrapper around cargo-release. +# +# Usage: +# release/release.sh patch # dry-run a patch release +# release/release.sh minor # dry-run a minor release +# release/release.sh major # dry-run a major release +# release/release.sh minor --execute # actually perform the release +# +# cargo-release is dry-run by default; --execute is required to mutate state. +# See release.toml for what a release rewrites (CHANGELOG.md, packaging/README.md) and +# for the `main`-only branch restriction. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: release.sh <patch|minor|major> [--execute]" >&2 + exit 2 +fi + +BUMP="$1" +shift + +exec cargo release "$BUMP" "$@" From 96bbd09a61e7a4814f30a9edca956aa4f22c3d23 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:19:12 +0200 Subject: [PATCH 10/50] chore: purge the remaining workspace-era references Doc comments in escape_dialect.rs and ffi_integration_tests.rs pointed at crates/stackable-odbc-sqlite/src/..., and the benchmark usage lines still carried -p crate selectors. A CI comment compared this repository's integration job to the sibling driver's; it now states the property that actually matters, which is that the suite needs no server or container. CHANGELOG.md still names the stackable-odbc-rs workspace. That is deliberate: it records where the code came from, which is what a changelog entry for an extraction is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/build.yaml | 6 +++--- benches/fetch_sqlite.rs | 4 ++-- src/escape_dialect.rs | 2 +- src/ffi_integration_tests.rs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5515a40..6691260 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -45,9 +45,9 @@ jobs: - name: Run unit tests run: cargo test - # Unlike the sibling Trino driver, whose integration suite needs a - # Dockerised coordinator and Postgres, this one needs only unixODBC and the - # sqlite3 CLI and runs comfortably on a standard runner. + # This suite needs only unixODBC and the sqlite3 CLI, no server and no + # container, so it runs on a standard runner in seconds and is worth + # gating every pull request on. sqlite-integration: name: SQLite Integration Tests runs-on: ubuntu-latest diff --git a/benches/fetch_sqlite.rs b/benches/fetch_sqlite.rs index 4b29758..80309ca 100644 --- a/benches/fetch_sqlite.rs +++ b/benches/fetch_sqlite.rs @@ -15,8 +15,8 @@ //! * repeat_get_data — SQLGetData called BENCH_REPEAT_GET_DATA times per cell //! //! Run: -//! cargo bench -p stackable-odbc-sqlite -//! BENCH_ROWS=1000000 cargo bench -p stackable-odbc-sqlite +//! cargo bench +//! BENCH_ROWS=1000000 cargo bench use std::ffi::c_void; use std::hint::black_box; diff --git a/src/escape_dialect.rs b/src/escape_dialect.rs index eb124c2..1709d0a 100644 --- a/src/escape_dialect.rs +++ b/src/escape_dialect.rs @@ -5,7 +5,7 @@ //! the bundled 3.53.2 build spells differently from ODBC. //! //! The remap table is traceable to the `SQL_*_FUNCTIONS` bitmaps -//! `crates/stackable-odbc-sqlite/src/backend/info.rs` advertises for SQLite. +//! `src/backend/info.rs` advertises for SQLite. //! Every arm below corresponds to one advertised `SQL_FN_*` //! bit whose ODBC name SQLite spells differently *and* for which a bare name //! substitution (`stackable_odbc_core::escape` only ever swaps the identifier in front diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 14b0d9f..64b7a31 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -1203,7 +1203,7 @@ fn sql_columns_w_result_set_reports_wvarchar_identifiers_and_narrow_data_type() // - DATA_TYPE (a SQL_SMALLINT column) has precision 5, not the old 50 // -- a SMALLINT cannot have 50 digits of precision. // A regression that reintroduces the old literals in - // crates/stackable-odbc-sqlite/src/backend/metadata.rs would only be caught + // src/backend/metadata.rs would only be caught // by the Python integration suite without this test. unsafe { let (env, conn, stmt) = alloc_handles(); From a00c2b8785cf6cd35aaf3bc7273c7e778d7ba34a Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:28:52 +0200 Subject: [PATCH 11/50] fix: correct SQLGetInfo values that contradict the ODBC spec Audits all 64 values this driver reports for SQLGetInfo -- including those answered by stackable-odbc-core's defaults, which an application cannot distinguish from the driver's own -- against the SQLGetInfo specification table and SQLite's documented behaviour. SQL_CATALOG_TERM, SQL_CATALOG_NAME_SEPARATOR and SQL_SCHEMA_TERM named a catalog, a separator and a schema while SQL_CATALOG_NAME, SQL_CATALOG_LOCATION, SQL_CATALOG_USAGE and SQL_SCHEMA_USAGE all declared that neither exists. The spec is explicit that the first three are empty strings when the data source supports neither. The driver answered four of the seven itself and let the other three fall through to core's defaults, so nothing held them together. All seven now derive from a single SUPPORTS_CATALOGS / SUPPORTS_SCHEMAS pair rather than being restated, and a test asserts the spec's rule rather than the current values, so it keeps holding if either constant flips. SQL_OUTER_JOIN_CAPABILITIES reported 0 -- no outer joins at all -- while this driver's own SQL_OUTER_JOINS reported "Y". It now reports the seven SQL_OJ_* bits SQLite implements, each proved by executing the join it describes against the bundled library rather than assumed from release notes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 18 +++ src/backend/info.rs | 306 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 302 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a26e606..8c89b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 advertised it and implemented nothing, so the driver reported that it destroyed cursors on commit while in fact preserving them. +- `SQL_OUTER_JOIN_CAPABILITIES` now reports every outer-join form SQLite + implements — `SQL_OJ_LEFT`, `SQL_OJ_RIGHT`, `SQL_OJ_FULL`, `SQL_OJ_NESTED`, + `SQL_OJ_NOT_ORDERED`, `SQL_OJ_INNER` and `SQL_OJ_ALL_COMPARISON_OPS` — instead + of `0`. It previously inherited `stackable-odbc-core`'s default of `0`, which + said SQLite supports no outer joins at all while this driver's own + `SQL_OUTER_JOINS` reported `"Y"`. Each bit is verified by executing the join + it describes against the bundled library, not assumed from release notes. + ### Fixed +- `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR` and `SQL_SCHEMA_TERM` now + report empty strings instead of `"catalog"`, `"."` and `"schema"`. The + `SQLGetInfo` specification requires an empty string from all three when the + data source supports neither catalogs nor schemas, which this driver has + always declared through `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION`, + `SQL_CATALOG_USAGE` and `SQL_SCHEMA_USAGE`. Applications were told catalogs do + not exist and given their name in the same breath. All seven values now derive + from a single `SUPPORTS_CATALOGS` / `SUPPORTS_SCHEMAS` pair, and a test asserts + they agree. + - `SQLCloseCursor` after a statement that produced no result set now returns `24000` rather than succeeding. An `INSERT` opens no cursor, so there is nothing to close; the call was accepted because cursor state was inferred diff --git a/src/backend/info.rs b/src/backend/info.rs index 74e6106..f62bdcf 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -9,23 +9,27 @@ use stackable_odbc_core::errors::OdbcError; use stackable_odbc_core::function_id::FunctionId; use stackable_odbc_core::types::{ InfoType, InfoValue, MaxPrecision, MaxScale, Nullable, SQL_AF_ALL, SQL_AF_AVG, SQL_AF_COUNT, - SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, SQL_CODE_DATE, - SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, SQL_FN_NUM_SIGN, - SQL_FN_STR_ASCII, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, SQL_FN_STR_LENGTH, - SQL_FN_STR_LTRIM, SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, - SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, SQL_FN_SYS_IFNULL, - SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, - SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, - SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, SQL_NC_LOW, SQL_NUMERIC_FUNCTIONS, SQL_OUTER_JOINS, - SQL_SEARCHABLE, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, - SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, + SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, SQL_CL_START, + SQL_CODE_DATE, SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_CU_DML_STATEMENTS, + SQL_CU_INDEX_DEFINITION, SQL_CU_TABLE_DEFINITION, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, + SQL_FN_NUM_SIGN, SQL_FN_STR_ASCII, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, + SQL_FN_STR_LENGTH, SQL_FN_STR_LTRIM, SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, + SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, + SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, + SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, + SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, SQL_NC_LOW, + SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, + SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, SQL_SEARCHABLE, + SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, SQL_SP_ISNULL, + SQL_SP_LIKE, SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, - SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, - SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_READ_COMMITTED, - SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, - TypeInfoRow, catalog_column_size, format_odbc_version, parse_dotted_version, + SQL_STRING_FUNCTIONS, SQL_SU_DML_STATEMENTS, SQL_SU_INDEX_DEFINITION, SQL_SU_TABLE_DEFINITION, + SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, + SQL_TIMEDATE_FUNCTIONS, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, + SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, TypeInfoRow, catalog_column_size, + format_odbc_version, parse_dotted_version, }; use super::SqliteBackend; @@ -36,6 +40,35 @@ use crate::type_conversion::{ VARCHAR_DEFAULT_COLUMN_SIZE, }; +/// Whether this driver exposes ODBC catalogs. It does not: `metadata::tables` +/// reports `TABLE_CAT` as NULL for every row, and a `catalog = "%"` enumeration +/// returns an empty result set. +/// +/// The `SQLGetInfo` specification defines five separate info types in terms of +/// this single fact — `SQL_CATALOG_NAME`, `SQL_CATALOG_TERM`, +/// `SQL_CATALOG_NAME_SEPARATOR`, `SQL_CATALOG_LOCATION` and +/// `SQL_CATALOG_USAGE` — so all five are derived from it here rather than +/// answered independently. +/// +/// That independence is what went wrong before: the driver answered +/// `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` itself +/// and let `SQL_CATALOG_TERM` and `SQL_CATALOG_NAME_SEPARATOR` fall through to +/// `stackable-odbc-core`'s defaults, which name a catalog and a separator. An +/// application was told catalogs do not exist and given their name in the same +/// breath. The spec is explicit for both: "An empty string is returned if +/// catalogs are not supported by the data source." +/// +/// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> +const SUPPORTS_CATALOGS: bool = false; + +/// Whether this driver exposes ODBC schemas. It does not: a `schema = "%"` +/// enumeration returns an empty result set and `TABLE_SCHEM` is always NULL. +/// +/// Derives `SQL_SCHEMA_TERM` and `SQL_SCHEMA_USAGE`, for the same reason +/// [`SUPPORTS_CATALOGS`] derives its five. The spec: "An empty string is +/// returned if schemas are not supported by the data source." +const SUPPORTS_SCHEMAS: bool = false; + /// ODBC function IDs for functions this driver implements. /// Used by `SQLGetFunctions` to report supported capabilities. /// Reference: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetfunctions-function> @@ -594,10 +627,65 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { } })); } - InfoType::SchemaUsage => return Ok(InfoValue::U32(0)), // SQLite has no schemas - InfoType::CatalogUsage => return Ok(InfoValue::U32(0)), // SQLite has no catalogs - InfoType::CatalogLocation => return Ok(InfoValue::U16(0)), // catalogs not supported - InfoType::CatalogName => return Ok(InfoValue::String("N".into())), + // Catalogs and schemas: every value below is derived from + // SUPPORTS_CATALOGS / SUPPORTS_SCHEMAS rather than restated, because + // the SQLGetInfo spec defines each of them in terms of that one fact. + InfoType::CatalogName => { + return Ok(InfoValue::String( + if SUPPORTS_CATALOGS { "Y" } else { "N" }.into(), + )); + } + InfoType::CatalogTerm => { + return Ok(InfoValue::String( + if SUPPORTS_CATALOGS { "catalog" } else { "" }.into(), + )); + } + InfoType::CatalogNameSeparator => { + return Ok(InfoValue::String( + if SUPPORTS_CATALOGS { "." } else { "" }.into(), + )); + } + InfoType::CatalogLocation => { + return Ok(InfoValue::U16(if SUPPORTS_CATALOGS { + SQL_CL_START + } else { + 0 + })); + } + InfoType::CatalogUsage => { + return Ok(InfoValue::U32(if SUPPORTS_CATALOGS { + SQL_CU_DML_STATEMENTS | SQL_CU_TABLE_DEFINITION | SQL_CU_INDEX_DEFINITION + } else { + 0 + })); + } + InfoType::SchemaTerm => { + return Ok(InfoValue::String( + if SUPPORTS_SCHEMAS { "schema" } else { "" }.into(), + )); + } + InfoType::SchemaUsage => { + return Ok(InfoValue::U32(if SUPPORTS_SCHEMAS { + SQL_SU_DML_STATEMENTS | SQL_SU_TABLE_DEFINITION | SQL_SU_INDEX_DEFINITION + } else { + 0 + })); + } + // Every outer-join form SQLite implements, and every relaxation of + // the ON clause the spec asks about. Core's default is 0, which + // contradicted this driver's own SQL_OUTER_JOINS = "Y". Each bit is + // exercised by `outer_join_capabilities_are_each_live_probed`. + InfoType::OuterJoinCapabilities => { + return Ok(InfoValue::U32( + SQL_OJ_LEFT + | SQL_OJ_RIGHT + | SQL_OJ_FULL + | SQL_OJ_NESTED + | SQL_OJ_NOT_ORDERED + | SQL_OJ_INNER + | SQL_OJ_ALL_COMPARISON_OPS, + )); + } InfoType::IdentifierCase => return Ok(InfoValue::U16(SQL_IC_MIXED)), InfoType::NullCollation => return Ok(InfoValue::U16(SQL_NC_LOW)), InfoType::DefaultTxnIsolation => return Ok(InfoValue::U32(SQL_TXN_SERIALIZABLE)), @@ -987,9 +1075,14 @@ mod tests { (InfoType::DriverOdbcVer, Expected::Str(SQL_DRIVER_ODBC_VER_STRING)), (InfoType::SearchPatternEscape, Expected::Str("\\")), (InfoType::IdentifierQuoteChar, Expected::Str("\"")), - (InfoType::CatalogTerm, Expected::Str("catalog")), - (InfoType::SchemaTerm, Expected::Str("schema")), - (InfoType::CatalogNameSeparator, Expected::Str(".")), + // Empty, not "catalog": derived from SUPPORTS_CATALOGS. The spec + // requires an empty string when catalogs are unsupported, which + // SQL_CATALOG_NAME = "N" declares. + (InfoType::CatalogTerm, Expected::Str("")), + // Empty, not "schema": derived from SUPPORTS_SCHEMAS, same spec rule. + (InfoType::SchemaTerm, Expected::Str("")), + // Empty, not ".": derived from SUPPORTS_CATALOGS, same spec rule. + (InfoType::CatalogNameSeparator, Expected::Str("")), (InfoType::ColumnAlias, Expected::Str("Y")), (InfoType::OrderByColumnsInSelect, Expected::Str("N")), (InfoType::CatalogName, Expected::Str("N")), @@ -1047,7 +1140,11 @@ mod tests { (InfoType::MaxIndexSize, Expected::U32(0)), (InfoType::MaxRowSize, Expected::U32(0)), (InfoType::MaxStatementLen, Expected::U32(0)), - (InfoType::OuterJoinCapabilities, Expected::U32(0)), + // Not 0: SQLite implements every outer-join form the spec asks + // about. Core's default of 0 contradicted SQL_OUTER_JOINS = "Y". + (InfoType::OuterJoinCapabilities, Expected::U32( + SQL_OJ_LEFT | SQL_OJ_RIGHT | SQL_OJ_FULL | SQL_OJ_NESTED + | SQL_OJ_NOT_ORDERED | SQL_OJ_INNER | SQL_OJ_ALL_COMPARISON_OPS)), (InfoType::SqlConformance, Expected::U32(SQL_SC_SQL92_ENTRY)), (InfoType::OdbcInterfaceConformance, Expected::U32(SQL_OIC_CORE)), (InfoType::AsyncMode, Expected::U32(SQL_AM_NONE)), @@ -1149,6 +1246,171 @@ mod tests { /// If a future `rusqlite`/`libsqlite3-sys` bump silently drops one of /// these compile flags, this test fails with a clear "no such function" /// error instead of the bitmap silently overclaiming forever. + /// The five catalog info types and the two schema info types must agree + /// with each other. This is the test the previous arrangement lacked: + /// `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` said + /// catalogs do not exist while `SQL_CATALOG_TERM` and + /// `SQL_CATALOG_NAME_SEPARATOR` fell through to core's defaults and named + /// one, and nothing tied the two groups together. + /// + /// Asserts the spec's rule, not the current values, so it keeps holding if + /// [`SUPPORTS_CATALOGS`] or [`SUPPORTS_SCHEMAS`] ever flips. + #[test] + fn catalog_and_schema_info_types_agree_with_each_other() { + let get = |t: InfoType| sqlite_get_info(t).expect("info type answered"); + + let catalogs_supported = matches!( + get(InfoType::CatalogName), + InfoValue::String(ref s) if s == "Y" + ); + assert_eq!( + catalogs_supported, SUPPORTS_CATALOGS, + "SQL_CATALOG_NAME must follow SUPPORTS_CATALOGS" + ); + + if catalogs_supported { + assert_ne!(get(InfoType::CatalogTerm), InfoValue::String(String::new())); + assert_ne!(get(InfoType::CatalogLocation), InfoValue::U16(0)); + } else { + // Spec: "An empty string is returned if catalogs are not supported + // by the data source" (SQL_CATALOG_TERM, SQL_CATALOG_NAME_SEPARATOR); + // "A value of 0 is returned if catalogs are not supported" + // (SQL_CATALOG_LOCATION, SQL_CATALOG_USAGE). + assert_eq!( + get(InfoType::CatalogTerm), + InfoValue::String(String::new()), + "SQL_CATALOG_TERM must be empty when catalogs are unsupported" + ); + assert_eq!( + get(InfoType::CatalogNameSeparator), + InfoValue::String(String::new()), + "SQL_CATALOG_NAME_SEPARATOR must be empty when catalogs are unsupported" + ); + assert_eq!( + get(InfoType::CatalogLocation), + InfoValue::U16(0), + "SQL_CATALOG_LOCATION must be 0 when catalogs are unsupported" + ); + assert_eq!( + get(InfoType::CatalogUsage), + InfoValue::U32(0), + "SQL_CATALOG_USAGE must be 0 when catalogs are unsupported" + ); + } + + if SUPPORTS_SCHEMAS { + assert_ne!(get(InfoType::SchemaTerm), InfoValue::String(String::new())); + } else { + assert_eq!( + get(InfoType::SchemaTerm), + InfoValue::String(String::new()), + "SQL_SCHEMA_TERM must be empty when schemas are unsupported" + ); + assert_eq!( + get(InfoType::SchemaUsage), + InfoValue::U32(0), + "SQL_SCHEMA_USAGE must be 0 when schemas are unsupported" + ); + } + } + + /// Every `SQL_OJ_*` bit this driver claims, proved by running the join it + /// describes rather than by reading release notes. `RIGHT` and `FULL` + /// arrived in SQLite 3.39.0; if a `rusqlite`/`libsqlite3-sys` downgrade + /// ever took the bundled library below that, this fails with a parse error + /// instead of the bitmap overclaiming forever. + /// + /// Core's default for `SQL_OUTER_JOIN_CAPABILITIES` is 0, which said + /// SQLite supports no outer joins at all while this driver's own + /// `SQL_OUTER_JOINS` said "Y". + #[test] + fn outer_join_capabilities_are_each_live_probed() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE l (id INTEGER, v TEXT); + CREATE TABLE r (id INTEGER, w TEXT); + CREATE TABLE m (id INTEGER, x TEXT); + INSERT INTO l VALUES (1, 'a'), (2, 'b'); + INSERT INTO r VALUES (2, 'B'), (3, 'C'); + -- m.id = 2 so it meets the row r contributes to the outer join; + -- the SQL_OJ_INNER probe below needs a real match, not just a + -- statement SQLite is willing to parse. + INSERT INTO m VALUES (2, 'y'), (3, 'z');", + ) + .unwrap(); + + // SQL_OJ_LEFT / SQL_OJ_RIGHT / SQL_OJ_FULL — the three join forms. + for (bit, sql) in [ + ( + SQL_OJ_LEFT, + "SELECT COUNT(*) FROM l LEFT OUTER JOIN r ON l.id = r.id", + ), + ( + SQL_OJ_RIGHT, + "SELECT COUNT(*) FROM l RIGHT OUTER JOIN r ON l.id = r.id", + ), + ( + SQL_OJ_FULL, + "SELECT COUNT(*) FROM l FULL OUTER JOIN r ON l.id = r.id", + ), + ] { + let n: i64 = conn + .query_row(sql, [], |row| row.get(0)) + .unwrap_or_else(|e| { + panic!("SQL_OJ bit {bit:#x} claimed but the join failed: {e}\n {sql}") + }); + assert!(n > 0, "SQL_OJ bit {bit:#x}: {sql} returned no rows"); + } + + // SQL_OJ_NESTED — an outer join whose operand is itself an outer join. + let nested: i64 = conn + .query_row( + "SELECT COUNT(*) FROM (l LEFT OUTER JOIN r ON l.id = r.id) \ + LEFT OUTER JOIN m ON r.id = m.id", + [], + |row| row.get(0), + ) + .expect("SQL_OJ_NESTED claimed but a nested outer join failed"); + assert!(nested > 0, "SQL_OJ_NESTED probe returned no rows"); + + // SQL_OJ_NOT_ORDERED — the ON-clause column order need not follow the + // table order in the FROM clause. + let not_ordered: i64 = conn + .query_row( + "SELECT COUNT(*) FROM l LEFT OUTER JOIN r ON r.id = l.id", + [], + |row| row.get(0), + ) + .expect("SQL_OJ_NOT_ORDERED claimed but a reversed ON clause failed"); + assert!(not_ordered > 0, "SQL_OJ_NOT_ORDERED probe returned no rows"); + + // SQL_OJ_INNER — the inner table of an outer join may also be used in + // an inner join. + let inner: i64 = conn + .query_row( + "SELECT COUNT(*) FROM l LEFT OUTER JOIN r ON l.id = r.id \ + INNER JOIN m ON m.id = r.id", + [], + |row| row.get(0), + ) + .expect("SQL_OJ_INNER claimed but mixing an inner join in failed"); + assert!(inner > 0, "SQL_OJ_INNER probe returned no rows"); + + // SQL_OJ_ALL_COMPARISON_OPS — the ON clause takes any comparison + // operator, not just equality. + let any_op: i64 = conn + .query_row( + "SELECT COUNT(*) FROM l LEFT OUTER JOIN r ON l.id < r.id", + [], + |row| row.get(0), + ) + .expect("SQL_OJ_ALL_COMPARISON_OPS claimed but a non-equality ON failed"); + assert!( + any_op > 0, + "SQL_OJ_ALL_COMPARISON_OPS probe returned no rows" + ); + } + #[test] fn live_sqlite_supports_sign_soundex_and_octet_length() { let conn = rusqlite::Connection::open_in_memory().unwrap(); From e8bf78140c023d2e3febb65d17b403af78d2dfbf Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:30:20 +0200 Subject: [PATCH 12/50] docs: fix an unresolved intra-doc link StatementBackend is not in scope in backend.rs, so the link in cursor_commit_behavior's docs did not resolve and cargo doc warned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- src/backend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend.rs b/src/backend.rs index 4131d7d..a8a60d3 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -313,7 +313,7 @@ impl Backend for SqliteBackend { /// /// If result sets ever become lazily streamed, revisit both hooks — and /// note that `SQL_CB_CLOSE` would then also require a real - /// [`StatementBackend::close_cursor`]. + /// [`stackable_odbc_core::backend::StatementBackend::close_cursor`]. /// /// Spec: <https://www.sqlite.org/lang_transaction.html> fn cursor_commit_behavior() -> CursorBehavior { From 8a74aa99ba1cc68b0fc6353306e67013f8fff1fe Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:43:57 +0200 Subject: [PATCH 13/50] fix: report the ALTER TABLE clauses SQLite actually supports SQL_ALTER_TABLE inherited stackable-odbc-core's default of 0, which said SQLite cannot alter a table in any way. It now reports ADD COLUMN with DEFAULT and COLLATE, ADD CONSTRAINT, and the CONSTRAINT name definition that clause carries. Every claimed bit is proved by executing the clause against the bundled library, and every unclaimed bit by that library rejecting it. The negative half earned its keep immediately: ADD CONSTRAINT and DROP CONSTRAINT were written off as absent from SQLite's grammar, which is true of 3.51.3 and false of the bundled 3.53.2. A bitmap that only checks what it claims would have understated indefinitely. ADD CONSTRAINT is additionally checked against the stored schema, not just for acceptance: SQLite's ADD clause makes the COLUMN keyword optional, so a statement adding a column called CONSTRAINT would otherwise satisfy the probe. Unqualified DROP COLUMN and DROP CONSTRAINT stay out of the bitmap. SQLite supports both, but the ODBC value offers only CASCADE and RESTRICT variants and SQLite rejects both keywords, so claiming either would advertise syntax an application would send and have refused. RENAME TO and RENAME COLUMN have no bit at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 10 ++ src/backend/info.rs | 225 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 202 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c89b2d..4383a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 advertised it and implemented nothing, so the driver reported that it destroyed cursors on commit while in fact preserving them. +- `SQL_ALTER_TABLE` now reports `SQL_AT_ADD_COLUMN_SINGLE`, + `SQL_AT_ADD_COLUMN_DEFAULT`, `SQL_AT_ADD_COLUMN_COLLATION`, + `SQL_AT_ADD_TABLE_CONSTRAINT` and `SQL_AT_CONSTRAINT_NAME_DEFINITION` instead + of `0`, which claimed SQLite cannot alter a table in any way. Each bit is + verified by executing the clause against the bundled library, and the bits + that stay off are verified to be rejected by it. SQLite's unqualified + `DROP COLUMN` and `DROP CONSTRAINT`, and both `RENAME` forms, remain absent + from the bitmap: the ODBC value has no bit for them, and its `CASCADE` and + `RESTRICT` variants are syntax errors in SQLite. + - `SQL_OUTER_JOIN_CAPABILITIES` now reports every outer-join form SQLite implements — `SQL_OJ_LEFT`, `SQL_OJ_RIGHT`, `SQL_OJ_FULL`, `SQL_OJ_NESTED`, `SQL_OJ_NOT_ORDERED`, `SQL_OJ_INNER` and `SQL_OJ_ALL_COMPARISON_OPS` — instead diff --git a/src/backend/info.rs b/src/backend/info.rs index f62bdcf..0c0407f 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -9,21 +9,22 @@ use stackable_odbc_core::errors::OdbcError; use stackable_odbc_core::function_id::FunctionId; use stackable_odbc_core::types::{ InfoType, InfoValue, MaxPrecision, MaxScale, Nullable, SQL_AF_ALL, SQL_AF_AVG, SQL_AF_COUNT, - SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, SQL_CL_START, - SQL_CODE_DATE, SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_CU_DML_STATEMENTS, - SQL_CU_INDEX_DEFINITION, SQL_CU_TABLE_DEFINITION, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, - SQL_FN_NUM_SIGN, SQL_FN_STR_ASCII, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, - SQL_FN_STR_LENGTH, SQL_FN_STR_LTRIM, SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, - SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, - SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, - SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, - SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, SQL_NC_LOW, - SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, - SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, SQL_SEARCHABLE, - SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, SQL_SP_ISNULL, - SQL_SP_LIKE, SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, - SQL_SQL92_VALUE_EXPRESSIONS, SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, - SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, + SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, + SQL_AT_ADD_COLUMN_COLLATION, SQL_AT_ADD_COLUMN_DEFAULT, SQL_AT_ADD_COLUMN_SINGLE, + SQL_AT_ADD_TABLE_CONSTRAINT, SQL_AT_CONSTRAINT_NAME_DEFINITION, SQL_CL_START, SQL_CODE_DATE, + SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_CU_DML_STATEMENTS, SQL_CU_INDEX_DEFINITION, + SQL_CU_TABLE_DEFINITION, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, SQL_FN_NUM_SIGN, SQL_FN_STR_ASCII, + SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, SQL_FN_STR_LENGTH, SQL_FN_STR_LTRIM, + SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, + SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, + SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, + SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, + SQL_LIKE_ESCAPE_CLAUSE, SQL_NC_LOW, SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, + SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, + SQL_OUTER_JOINS, SQL_SEARCHABLE, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, + SQL_SP_ISNOTNULL, SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SQL92_PREDICATES, + SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, SQL_SRJO_CROSS_JOIN, + SQL_SRJO_EXCEPT_JOIN, SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, SQL_STRING_FUNCTIONS, SQL_SU_DML_STATEMENTS, SQL_SU_INDEX_DEFINITION, SQL_SU_TABLE_DEFINITION, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, @@ -675,6 +676,7 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { // the ON clause the spec asks about. Core's default is 0, which // contradicted this driver's own SQL_OUTER_JOINS = "Y". Each bit is // exercised by `outer_join_capabilities_are_each_live_probed`. + InfoType::AlterTable => return Ok(InfoValue::U32(SQLITE_ALTER_TABLE)), InfoType::OuterJoinCapabilities => { return Ok(InfoValue::U32( SQL_OJ_LEFT @@ -746,6 +748,48 @@ pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, Odb pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = SQL_AF_AVG | SQL_AF_COUNT | SQL_AF_MAX | SQL_AF_MIN | SQL_AF_SUM | SQL_AF_DISTINCT | SQL_AF_ALL; +/// `SQL_ALTER_TABLE` (86) — the `ALTER TABLE` clauses SQLite accepts, of those +/// the ODBC bitmap can express. +/// +/// Every bit here was established by executing the clause against the bundled +/// library (3.53.2), not read off the documentation — +/// `alter_table_capabilities_are_each_live_probed` is that probe, and it +/// checks the unclaimed bits too. That matters: `ADD CONSTRAINT` and +/// `DROP CONSTRAINT` are recent additions, rejected by 3.51.3 and accepted by +/// 3.53.2, so a bitmap written from an older recollection of SQLite's grammar +/// understates it. +/// +/// Claimed: +/// +/// - `ADD COLUMN`, with `DEFAULT` and `COLLATE`. +/// - `ADD CONSTRAINT <name> CHECK (...)`, which rewrites the stored schema to +/// carry a genuine table constraint. Note the ODBC bit is all-or-nothing +/// while SQLite accepts only `CHECK` here — `UNIQUE`, `PRIMARY KEY` and +/// `FOREIGN KEY` are still syntax errors. +/// - `SQL_AT_CONSTRAINT_NAME_DEFINITION`, since that `CONSTRAINT <name>` clause +/// is exactly what the bit describes. +/// +/// Supported by SQLite but *unrepresentable*, so absent by necessity rather +/// than because SQLite lacks them: unqualified `DROP COLUMN` (3.35.0+) and +/// unqualified `DROP CONSTRAINT`, for which the bitmap offers only `CASCADE` +/// and `RESTRICT` variants — and SQLite rejects both keywords, so claiming +/// either would advertise a syntax an application would send and have refused. +/// `RENAME TO` and `RENAME COLUMN` have no `SQL_AT_*` bit at all. +/// +/// Genuinely absent: `ALTER COLUMN ... SET DEFAULT` and +/// `ALTER COLUMN ... DROP DEFAULT` are not SQLite grammar. +/// +/// Core previously defaulted this to 0, which said SQLite cannot alter a table +/// in any way. +/// +/// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> +/// SQLite: <https://www.sqlite.org/lang_altertable.html> +pub(crate) const SQLITE_ALTER_TABLE: u32 = SQL_AT_ADD_COLUMN_SINGLE + | SQL_AT_ADD_COLUMN_DEFAULT + | SQL_AT_ADD_COLUMN_COLLATION + | SQL_AT_ADD_TABLE_CONSTRAINT + | SQL_AT_CONSTRAINT_NAME_DEFINITION; + /// `SQL_SQL92_PREDICATES`. /// /// Deliberately absent: quantified comparison (`< ALL` / `< ANY` / `< SOME` @@ -1042,23 +1086,26 @@ mod tests { } use super::*; use stackable_odbc_core::types::{ - DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_CA1_NEXT, SQL_CB_PRESERVE, - SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, - SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, - SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, - SQL_FN_STR_CHARACTER_LENGTH, SQL_FN_STR_DIFFERENCE, SQL_FN_STR_INSERT, SQL_FN_STR_LEFT, - SQL_FN_STR_LOCATE, SQL_FN_STR_LOCATE_2, SQL_FN_STR_POSITION, SQL_FN_STR_REPEAT, - SQL_FN_STR_RIGHT, SQL_FN_STR_SPACE, SQL_FN_TD_DAYNAME, SQL_FN_TD_DAYOFMONTH, - SQL_FN_TD_EXTRACT, SQL_FN_TD_MONTH, SQL_FN_TD_MONTHNAME, SQL_FN_TD_QUARTER, - SQL_FN_TD_TIMESTAMPADD, SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_YEAR, SQL_GB_NO_RELATION, - SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_INSENSITIVE, - SQL_MAX_CURSOR_NAME_LEN, SQL_NC_LOW, SQL_OIC_CORE, SQL_SC_SQL92_ENTRY, SQL_SO_FORWARD_ONLY, - SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, - SQL_SP_MATCH_UNIQUE_PARTIAL, SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, - SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, - SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, - SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, - SQL_TXN_SERIALIZABLE, SQL_U_UNION, SQL_U_UNION_ALL, + DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_AT_DROP_COLUMN_CASCADE, + SQL_AT_DROP_COLUMN_DEFAULT, SQL_AT_DROP_COLUMN_RESTRICT, + SQL_AT_DROP_TABLE_CONSTRAINT_CASCADE, SQL_AT_DROP_TABLE_CONSTRAINT_RESTRICT, + SQL_AT_SET_COLUMN_DEFAULT, SQL_CA1_NEXT, SQL_CB_PRESERVE, SQL_DRIVER_ODBC_VER_STRING, + SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, + SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, + SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, SQL_FN_STR_CHARACTER_LENGTH, + SQL_FN_STR_DIFFERENCE, SQL_FN_STR_INSERT, SQL_FN_STR_LEFT, SQL_FN_STR_LOCATE, + SQL_FN_STR_LOCATE_2, SQL_FN_STR_POSITION, SQL_FN_STR_REPEAT, SQL_FN_STR_RIGHT, + SQL_FN_STR_SPACE, SQL_FN_TD_DAYNAME, SQL_FN_TD_DAYOFMONTH, SQL_FN_TD_EXTRACT, + SQL_FN_TD_MONTH, SQL_FN_TD_MONTHNAME, SQL_FN_TD_QUARTER, SQL_FN_TD_TIMESTAMPADD, + SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_YEAR, SQL_GB_NO_RELATION, SQL_GD_ANY_COLUMN, + SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_INSENSITIVE, SQL_MAX_CURSOR_NAME_LEN, + SQL_NC_LOW, SQL_OIC_CORE, SQL_SC_SQL92_ENTRY, SQL_SO_FORWARD_ONLY, SQL_SP_MATCH_FULL, + SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, SQL_SP_MATCH_UNIQUE_PARTIAL, + SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, SQL_SQ_COMPARISON, + SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, SQL_SQ_QUANTIFIED, + SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, SQL_TXN_READ_COMMITTED, + SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SQL_U_UNION, + SQL_U_UNION_ALL, }; enum Expected { @@ -1136,7 +1183,7 @@ mod tests { (InfoType::ScrollOptions, Expected::U32(SQL_SO_FORWARD_ONLY)), (InfoType::ConvertFunctions, Expected::U32(SQL_FN_CVT_CAST)), (InfoType::TransactionIsolationProtocol, Expected::U32(SQL_TXN_READ_UNCOMMITTED | SQL_TXN_READ_COMMITTED | SQL_TXN_REPEATABLE_READ | SQL_TXN_SERIALIZABLE)), - (InfoType::AlterTable, Expected::U32(0)), + (InfoType::AlterTable, Expected::U32(SQLITE_ALTER_TABLE)), (InfoType::MaxIndexSize, Expected::U32(0)), (InfoType::MaxRowSize, Expected::U32(0)), (InfoType::MaxStatementLen, Expected::U32(0)), @@ -1314,6 +1361,118 @@ mod tests { } } + /// Every `SQL_AT_*` bit this driver claims, proved by running the + /// `ALTER TABLE` it describes — and every bit it does *not* claim, proved + /// by the bundled library rejecting that syntax. + /// + /// The negative half is the point. A bitmap that only checks what it claims + /// can overclaim forever; these assertions fail the moment SQLite gains a + /// clause the bitmap still denies, which is the cheapest possible reminder + /// to widen it. + #[test] + fn alter_table_capabilities_are_each_live_probed() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch("CREATE TABLE t (id INTEGER)").unwrap(); + + // Claimed: each must be accepted. + for (bit, sql) in [ + (SQL_AT_ADD_COLUMN_SINGLE, "ALTER TABLE t ADD COLUMN c1 TEXT"), + ( + SQL_AT_ADD_COLUMN_DEFAULT, + "ALTER TABLE t ADD COLUMN c2 TEXT DEFAULT 'x'", + ), + ( + SQL_AT_ADD_COLUMN_COLLATION, + "ALTER TABLE t ADD COLUMN c3 TEXT COLLATE NOCASE", + ), + ( + SQL_AT_ADD_TABLE_CONSTRAINT | SQL_AT_CONSTRAINT_NAME_DEFINITION, + "ALTER TABLE t ADD CONSTRAINT ck CHECK (id > 0)", + ), + ] { + assert!( + SQLITE_ALTER_TABLE & bit == bit, + "probe listed for unclaimed bit {bit:#x}" + ); + conn.execute_batch(sql).unwrap_or_else(|e| { + panic!("SQL_AT bit {bit:#x} claimed but SQLite rejected it: {e}\n {sql}") + }); + } + + // ADD CONSTRAINT must produce a real table constraint, not a column + // that merely parses. Without this, `ADD CONSTRAINT ck CHECK (...)` + // could be read as a column named CONSTRAINT and the bit would be a + // lie that still passes the acceptance probe above. + let schema: String = conn + .query_row("SELECT sql FROM sqlite_master WHERE name = 't'", [], |r| { + r.get(0) + }) + .unwrap(); + assert!( + schema.contains("CONSTRAINT ck CHECK"), + "SQL_AT_ADD_TABLE_CONSTRAINT claimed, but the stored schema shows no \ + named table constraint: {schema}" + ); + + // Not claimed: each must be rejected. ALTER COLUMN is not SQLite + // grammar at all; the CASCADE and RESTRICT qualifiers are not accepted + // on either DROP form, which is why those four bits stay off even + // though SQLite drops both columns and constraints. + for (bit, sql) in [ + ( + SQL_AT_SET_COLUMN_DEFAULT, + "ALTER TABLE t ALTER COLUMN c1 SET DEFAULT 'y'", + ), + ( + SQL_AT_DROP_COLUMN_DEFAULT, + "ALTER TABLE t ALTER COLUMN c2 DROP DEFAULT", + ), + ( + SQL_AT_DROP_COLUMN_CASCADE, + "ALTER TABLE t DROP COLUMN c1 CASCADE", + ), + ( + SQL_AT_DROP_COLUMN_RESTRICT, + "ALTER TABLE t DROP COLUMN c1 RESTRICT", + ), + ( + SQL_AT_DROP_TABLE_CONSTRAINT_CASCADE, + "ALTER TABLE t DROP CONSTRAINT ck CASCADE", + ), + ( + SQL_AT_DROP_TABLE_CONSTRAINT_RESTRICT, + "ALTER TABLE t DROP CONSTRAINT ck RESTRICT", + ), + ] { + assert!( + SQLITE_ALTER_TABLE & bit == 0, + "negative probe listed for claimed bit {bit:#x}" + ); + assert!( + conn.execute_batch(sql).is_err(), + "SQL_AT bit {bit:#x} is not claimed, but SQLite accepted it -- \ + SQLITE_ALTER_TABLE now understates and should be widened\n {sql}" + ); + } + + // ADD CONSTRAINT only takes CHECK. The ODBC bit cannot express that + // narrowing, so it is recorded here instead. + assert!( + conn.execute_batch("ALTER TABLE t ADD CONSTRAINT uq UNIQUE (id)") + .is_err(), + "SQLite gained ADD CONSTRAINT ... UNIQUE; the doc comment on \ + SQLITE_ALTER_TABLE says only CHECK is accepted and needs updating" + ); + + // Supported by SQLite but unrepresentable: neither unqualified form has + // a SQL_AT_* bit, so both are absent by necessity rather than because + // SQLite lacks them. Asserted so that is not mistaken for an oversight. + conn.execute_batch("ALTER TABLE t DROP CONSTRAINT ck") + .expect("SQLite supports unqualified DROP CONSTRAINT"); + conn.execute_batch("ALTER TABLE t DROP COLUMN c1") + .expect("SQLite supports unqualified DROP COLUMN (3.35.0+)"); + } + /// Every `SQL_OJ_*` bit this driver claims, proved by running the join it /// describes rather than by reading release notes. `RIGHT` and `FULL` /// arrived in SQLite 3.39.0; if a `rusqlite`/`libsqlite3-sys` downgrade From 5ef6fb9b19d94029d92e6365a9756d26fcc83927 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 18:52:44 +0200 Subject: [PATCH 14/50] fix: advertise only the transaction isolation level SQLite implements SQL_TXN_ISOLATION_OPTION claimed READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ and SERIALIZABLE. SQLite provides the last of these: "Transactions in SQLite are SERIALIZABLE". READ COMMITTED and REPEATABLE READ are not SQLite concepts at all, and READ UNCOMMITTED additionally requires shared-cache mode -- "the only way that one database connection can see uncommitted changes on a different database connection" -- which SqliteBackend::connect never enables, opening with a plain rusqlite::Connection::open. The overclaim mattered because nothing applies what an application asks for. SQL_ATTR_TXN_ISOLATION is stored on the connection and read back unchanged, never pushed to SQLite, so an application that set REPEATABLE READ was told it had it while running serializable. A test now asserts that SQL_DEFAULT_TXN_ISOLATION names a level SQL_TXN_ISOLATION_OPTION actually offers, which is the invariant the two values were breaking. Spec: https://www.sqlite.org/isolation.html Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 9 ++++++ src/backend/info.rs | 79 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4383a4d..b0592d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 advertised it and implemented nothing, so the driver reported that it destroyed cursors on commit while in fact preserving them. +- `SQL_TXN_ISOLATION_OPTION` now reports `SQL_TXN_SERIALIZABLE` alone, instead + of also advertising `SQL_TXN_READ_UNCOMMITTED`, `SQL_TXN_READ_COMMITTED` and + `SQL_TXN_REPEATABLE_READ`. Transactions in SQLite are serializable; READ + COMMITTED and REPEATABLE READ are not SQLite concepts, and READ UNCOMMITTED + additionally requires shared-cache mode, which this driver never enables. + Nothing applied the level an application set in any case — + `SQL_ATTR_TXN_ISOLATION` is stored on the connection and read back unchanged + — so the three extra levels promised behaviour no code path delivered. + - `SQL_ALTER_TABLE` now reports `SQL_AT_ADD_COLUMN_SINGLE`, `SQL_AT_ADD_COLUMN_DEFAULT`, `SQL_AT_ADD_COLUMN_COLLATION`, `SQL_AT_ADD_TABLE_CONSTRAINT` and `SQL_AT_CONSTRAINT_NAME_DEFINITION` instead diff --git a/src/backend/info.rs b/src/backend/info.rs index 0c0407f..5d1bf6f 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -28,8 +28,7 @@ use stackable_odbc_core::types::{ SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, SQL_STRING_FUNCTIONS, SQL_SU_DML_STATEMENTS, SQL_SU_INDEX_DEFINITION, SQL_SU_TABLE_DEFINITION, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, - SQL_TIMEDATE_FUNCTIONS, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, - SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, TypeInfoRow, catalog_column_size, + SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, SqlDataType, TypeInfoRow, catalog_column_size, format_odbc_version, parse_dotted_version, }; @@ -691,13 +690,26 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { InfoType::IdentifierCase => return Ok(InfoValue::U16(SQL_IC_MIXED)), InfoType::NullCollation => return Ok(InfoValue::U16(SQL_NC_LOW)), InfoType::DefaultTxnIsolation => return Ok(InfoValue::U32(SQL_TXN_SERIALIZABLE)), + // Only SERIALIZABLE. "Transactions in SQLite are SERIALIZABLE", and + // READ COMMITTED and REPEATABLE READ do not exist in SQLite at all. + // + // READ UNCOMMITTED is deliberately not claimed either. It requires + // shared-cache mode as well as `PRAGMA read_uncommitted`: "The + // combined use of shared cache mode and the read_uncommitted pragma is + // the only way that one database connection can see uncommitted + // changes on a different database connection." This driver opens with + // a plain `rusqlite::Connection::open`, so shared cache is off and the + // level is unreachable. + // + // This previously advertised all four levels. Nothing applies the + // value an application sets -- `SQL_ATTR_TXN_ISOLATION` is stored on + // the connection and read back, never pushed to SQLite -- so an + // application that asked for REPEATABLE READ was told it had it while + // running serializable. + // + // Spec: <https://www.sqlite.org/isolation.html> InfoType::TransactionIsolationProtocol => { - return Ok(InfoValue::U32( - SQL_TXN_READ_UNCOMMITTED - | SQL_TXN_READ_COMMITTED - | SQL_TXN_REPEATABLE_READ - | SQL_TXN_SERIALIZABLE, - )); + return Ok(InfoValue::U32(SQL_TXN_SERIALIZABLE)); } // SQL_TXN_CAPABLE is `An SQLUSMALLINT value` per the SQLGetInfo spec, // not SQLUINTEGER -- found by the info-type conformance test @@ -1182,7 +1194,10 @@ mod tests { (InfoType::DefaultTxnIsolation, Expected::U32(SQL_TXN_SERIALIZABLE)), (InfoType::ScrollOptions, Expected::U32(SQL_SO_FORWARD_ONLY)), (InfoType::ConvertFunctions, Expected::U32(SQL_FN_CVT_CAST)), - (InfoType::TransactionIsolationProtocol, Expected::U32(SQL_TXN_READ_UNCOMMITTED | SQL_TXN_READ_COMMITTED | SQL_TXN_REPEATABLE_READ | SQL_TXN_SERIALIZABLE)), + // SERIALIZABLE only: READ COMMITTED and REPEATABLE READ do not exist + // in SQLite, and READ UNCOMMITTED needs shared-cache mode, which this + // driver never enables. + (InfoType::TransactionIsolationProtocol, Expected::U32(SQL_TXN_SERIALIZABLE)), (InfoType::AlterTable, Expected::U32(SQLITE_ALTER_TABLE)), (InfoType::MaxIndexSize, Expected::U32(0)), (InfoType::MaxRowSize, Expected::U32(0)), @@ -1361,6 +1376,52 @@ mod tests { } } + /// `SQL_DEFAULT_TXN_ISOLATION` must name a level that + /// `SQL_TXN_ISOLATION_OPTION` actually offers, and this driver offers + /// exactly one. + /// + /// SQLite is serializable and has no way to be anything else here: READ + /// COMMITTED and REPEATABLE READ are not SQLite concepts, and READ + /// UNCOMMITTED needs shared-cache mode, which `SqliteBackend::connect` + /// never enables. The bitmap previously advertised all four. + /// + /// This matters more than an unused info value usually would, because + /// nothing applies what an application sets: `SQL_ATTR_TXN_ISOLATION` is + /// stored on the connection and read back unchanged, never pushed to + /// SQLite. Advertising a level therefore promises something no code path + /// delivers. + #[test] + fn transaction_isolation_offers_only_the_level_sqlite_implements() { + let supported = match sqlite_get_info(InfoType::TransactionIsolationProtocol) { + Ok(InfoValue::U32(v)) => v, + other => panic!("unexpected shape: {other:?}"), + }; + let default = match sqlite_get_info(InfoType::DefaultTxnIsolation) { + Ok(InfoValue::U32(v)) => v, + other => panic!("unexpected shape: {other:?}"), + }; + + assert_eq!( + supported, SQL_TXN_SERIALIZABLE, + "SQLite is serializable and offers no other level reachable from this driver" + ); + assert!( + supported & default == default, + "SQL_DEFAULT_TXN_ISOLATION ({default:#x}) is not in \ + SQL_TXN_ISOLATION_OPTION ({supported:#x})" + ); + for absent in [ + SQL_TXN_READ_UNCOMMITTED, + SQL_TXN_READ_COMMITTED, + SQL_TXN_REPEATABLE_READ, + ] { + assert!( + supported & absent == 0, + "isolation level {absent:#x} advertised, but SQLite cannot provide it" + ); + } + } + /// Every `SQL_AT_*` bit this driver claims, proved by running the /// `ALTER TABLE` it describes — and every bit it does *not* claim, proved /// by the bundled library rejecting that syntax. From f33ef317e25660f0c019f9503adcf6b19f015db9 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 19:59:00 +0200 Subject: [PATCH 15/50] feat!: state the SQLGetInfo values core no longer invents stackable-odbc-core made fourteen Backend methods required, on the grounds that a defaulted capability is a claim no backend ever made. This driver now answers all of them. Six carry values this repository had already established, moved from sqlite_get_info arms and local constants into the hooks: supports_catalogs, supports_schemas, alter_table_support, outer_join_capabilities, default_txn_isolation and txn_isolation_options. The seven catalog and schema info types are no longer answered here at all -- core derives the whole group from the two booleans, and the snapshot is unchanged, which is the evidence that the derivation reproduces what the arms produced. Eight are new. group_by, null_collation, correlation_name, non_nullable_columns and expressions_in_order_by were each verified against the bundled library rather than reasoned about; the two timedate interval bitmaps report 0, matching SQL_TIMEDATE_FUNCTIONS, which claims neither TIMESTAMPADD nor TIMESTAMPDIFF. sql_conformance drops SQL_SC_SQL92_ENTRY for 0. That value was core's default, not an assessment of SQLite, and the spec ties entry level to SQL_GB_GROUP_BY_EQUALS_SELECT -- while SQLite accepts a bare non-aggregated column absent from GROUP BY, and a GROUP BY column absent from the select list. Both are asserted. Claiming no level is the honest answer; raising it means auditing entry-level conformance rather than restoring an invented value. SQL_ALTER_TABLE gains SQL_AT_ADD_CONSTRAINT, which core did not define when this bitmap was first written. The bit means ADD COLUMN with column constraints, not table constraints, and SQLite takes NOT NULL, CHECK, REFERENCES and a named CONSTRAINT there. The four deferrability bits stay unclaimed: SQLite implements deferred constraints only inside a foreign-key clause, and its parser accepts DEFERRABLE after CHECK and NOT NULL where it has no effect -- accepting a token is not implementing the attribute. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 23 ++++ src/backend.rs | 130 ++++++++++++++++++++++ src/backend/info.rs | 262 +++++++++++++++++++++----------------------- 3 files changed, 276 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0592d1..271f360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 advertised it and implemented nothing, so the driver reported that it destroyed cursors on commit while in fact preserving them. +- `SQL_SQL_CONFORMANCE` now reports `0` — no SQL-92 level claimed — instead of + `SQL_SC_SQL92_ENTRY`. That value came from a `stackable-odbc-core` default + rather than any assessment of SQLite, and it contradicted this driver's own + answers: the spec ties entry level to `SQL_GB_GROUP_BY_EQUALS_SELECT`, while + SQLite accepts a bare non-aggregated column absent from `GROUP BY` and a + `GROUP BY` column absent from the select list. Raising the claim later means + auditing entry-level conformance properly. + +- `SQL_ALTER_TABLE` additionally reports `SQL_AT_ADD_CONSTRAINT`. Despite its + name that bit means "`ADD COLUMN` is supported with column constraints", and + SQLite accepts `NOT NULL`, `CHECK`, `REFERENCES` and a named `CONSTRAINT` on + an added column; only `UNIQUE` and `PRIMARY KEY` are refused. The bit was + unavailable when this driver first set the bitmap. + +- `SQL_GROUP_BY`, `SQL_NULL_COLLATION`, `SQL_CORRELATION_NAME`, + `SQL_NON_NULLABLE_COLUMNS`, `SQL_EXPRESSIONS_IN_ORDERBY`, + `SQL_TIMEDATE_ADD_INTERVALS` and `SQL_TIMEDATE_DIFF_INTERVALS` are now stated + by this driver rather than inherited. `SQL_CORRELATION_NAME` + (`SQL_CN_ANY`), `SQL_NON_NULLABLE_COLUMNS` (`SQL_NNC_NON_NULL`) and + `SQL_EXPRESSIONS_IN_ORDERBY` (`"Y"`) had never been asserted anywhere; the + two interval bitmaps report `0`, matching `SQL_TIMEDATE_FUNCTIONS`, which + does not claim `TIMESTAMPADD` or `TIMESTAMPDIFF`. + - `SQL_TXN_ISOLATION_OPTION` now reports `SQL_TXN_SERIALIZABLE` alone, instead of also advertising `SQL_TXN_READ_UNCOMMITTED`, `SQL_TXN_READ_COMMITTED` and `SQL_TXN_REPEATABLE_READ`. Transactions in SQLite are serializable; READ diff --git a/src/backend.rs b/src/backend.rs index a8a60d3..13555c2 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -6,6 +6,7 @@ use stackable_odbc_core::{ errors::OdbcError, types::{ ColumnDescriptor, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, InfoValue, + SQL_CN_ANY, SQL_GB_NO_RELATION, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TXN_SERIALIZABLE, TypeInfoRow, }, }; @@ -326,6 +327,135 @@ impl Backend for SqliteBackend { CursorBehavior::Preserve } + /// SQLite has no ODBC catalogs: `metadata::tables` reports `TABLE_CAT` as + /// NULL for every row, and a `catalog = "%"` enumeration returns an empty + /// result set. + /// + /// Core derives the whole catalog group from this — `SQL_CATALOG_NAME`, + /// `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR`, + /// `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` — so this driver answers + /// none of them itself. Before the hook existed it answered three and let + /// the other two inherit defaults that named a catalog, telling an + /// application catalogs do not exist and giving their name in the same + /// breath. + fn supports_catalogs() -> bool { + false + } + + /// SQLite has no ODBC schemas: a `schema = "%"` enumeration returns an + /// empty result set and `TABLE_SCHEM` is always NULL. + /// + /// Drives `SQL_SCHEMA_TERM` and `SQL_SCHEMA_USAGE`; see + /// [`SqliteBackend::supports_catalogs`]. + fn supports_schemas() -> bool { + false + } + + /// The `ALTER TABLE` clauses SQLite accepts, of those the ODBC bitmap can + /// express. See `info::SQLITE_ALTER_TABLE` for what is claimed, what is + /// supported-but-unrepresentable, and how each bit was verified. + fn alter_table_support() -> u32 { + info::SQLITE_ALTER_TABLE + } + + /// Every outer-join form SQLite implements. See + /// `info::SQLITE_OUTER_JOIN_CAPABILITIES`. + fn outer_join_capabilities() -> u32 { + info::SQLITE_OUTER_JOIN_CAPABILITIES + } + + /// "Transactions in SQLite are SERIALIZABLE." + /// + /// Core derives both `SQL_DEFAULT_TXN_ISOLATION` and the value + /// `SQLGetConnectAttr(SQL_ATTR_TXN_ISOLATION)` reports on a fresh + /// connection from this, so the two cannot disagree. + /// + /// Spec: <https://www.sqlite.org/isolation.html> + fn default_txn_isolation() -> u32 { + SQL_TXN_SERIALIZABLE + } + + /// The only level reachable from this driver. + /// + /// READ COMMITTED and REPEATABLE READ are not SQLite concepts. READ + /// UNCOMMITTED needs shared-cache mode — "the only way that one database + /// connection can see uncommitted changes on a different database + /// connection" — and [`SqliteBackend::connect`] opens with a plain + /// `rusqlite::Connection::open`, so it is unreachable. + /// + /// Returning a single level also means core's default + /// [`Backend::set_txn_isolation`] is correct as-is: the one supported + /// level is always already in effect, and anything else is rejected with + /// `HY024` before it reaches the backend. + fn txn_isolation_options() -> u32 { + SQL_TXN_SERIALIZABLE + } + + /// `SQL_GB_NO_RELATION`: SQLite relates the `GROUP BY` list and the select + /// list not at all. It accepts a bare non-aggregated column absent from + /// `GROUP BY` (returning an arbitrary row from each group), and accepts + /// `GROUP BY` columns and expressions absent from the select list. + /// + /// Verified in `group_by_is_unrelated_to_the_select_list`. + fn group_by() -> u16 { + SQL_GB_NO_RELATION + } + + /// `SQL_NC_LOW`: SQLite sorts NULLs at the low end — first ascending, last + /// descending. + fn null_collation() -> u16 { + SQL_NC_LOW + } + + /// `SQL_CN_ANY`: SQLite accepts a table alias with or without `AS`, and + /// places no restriction on the name. + fn correlation_name() -> u16 { + SQL_CN_ANY + } + + /// `SQL_NNC_NON_NULL`: SQLite implements `NOT NULL` column constraints. + fn non_nullable_columns() -> u16 { + SQL_NNC_NON_NULL + } + + /// SQLite takes arbitrary expressions in `ORDER BY`, including over columns + /// absent from the select list. + fn expressions_in_order_by() -> bool { + true + } + + /// No SQL-92 conformance level is claimed. + /// + /// The previous `SQL_SC_SQL92_ENTRY` came from a core default, not from any + /// assessment of SQLite, and it contradicted this driver's own answers. The + /// spec ties entry level to three values: "a SQL-92 Entry level-conformant + /// driver will always return the SQL_GB_GROUP_BY_EQUALS_SELECT option as + /// supported", "will always return SQL_CN_ANY", and "will return + /// SQL_NNC_NON_NULL". This driver matches the last two and cannot match the + /// first — SQLite's `GROUP BY` is deliberately unrelated to the select list + /// (see [`SqliteBackend::group_by`]), which is a permissive extension, not + /// entry-level behaviour. + /// + /// `0` is the honest answer: it claims no level rather than asserting one + /// the driver demonstrably fails. Raising it later means auditing SQL-92 + /// entry level properly, not restoring the value core used to invent. + fn sql_conformance() -> u32 { + 0 + } + + /// `0`: `TIMESTAMPADD` is not supported. `SQLITE_TIMEDATE_FUNCTIONS` + /// deliberately omits `SQL_FN_TD_TIMESTAMPADD`, so claiming interval units + /// here would describe a function this driver does not offer. + fn timedate_add_intervals() -> u32 { + 0 + } + + /// `0`: `TIMESTAMPDIFF` is not supported, for the same reason as + /// [`SqliteBackend::timedate_add_intervals`]. + fn timedate_diff_intervals() -> u32 { + 0 + } + // --- Delegations --- fn exec_direct(conn: &SqliteConnection, sql: &str) -> Result<SqliteStatement, SqliteError> { diff --git a/src/backend/info.rs b/src/backend/info.rs index 5d1bf6f..cef8d45 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -11,25 +11,23 @@ use stackable_odbc_core::types::{ InfoType, InfoValue, MaxPrecision, MaxScale, Nullable, SQL_AF_ALL, SQL_AF_AVG, SQL_AF_COUNT, SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, SQL_AT_ADD_COLUMN_COLLATION, SQL_AT_ADD_COLUMN_DEFAULT, SQL_AT_ADD_COLUMN_SINGLE, - SQL_AT_ADD_TABLE_CONSTRAINT, SQL_AT_CONSTRAINT_NAME_DEFINITION, SQL_CL_START, SQL_CODE_DATE, - SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_CU_DML_STATEMENTS, SQL_CU_INDEX_DEFINITION, - SQL_CU_TABLE_DEFINITION, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, SQL_FN_NUM_SIGN, SQL_FN_STR_ASCII, - SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, SQL_FN_STR_LENGTH, SQL_FN_STR_LTRIM, - SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, - SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, - SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, - SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, - SQL_LIKE_ESCAPE_CLAUSE, SQL_NC_LOW, SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, - SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, - SQL_OUTER_JOINS, SQL_SEARCHABLE, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, - SQL_SP_ISNOTNULL, SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SQL92_PREDICATES, - SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, SQL_SRJO_CROSS_JOIN, - SQL_SRJO_EXCEPT_JOIN, SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, - SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, - SQL_STRING_FUNCTIONS, SQL_SU_DML_STATEMENTS, SQL_SU_INDEX_DEFINITION, SQL_SU_TABLE_DEFINITION, - SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, - SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, SqlDataType, TypeInfoRow, catalog_column_size, - format_odbc_version, parse_dotted_version, + SQL_AT_ADD_CONSTRAINT, SQL_AT_ADD_TABLE_CONSTRAINT, SQL_AT_CONSTRAINT_NAME_DEFINITION, + SQL_CODE_DATE, SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, + SQL_FN_NUM_SIGN, SQL_FN_STR_ASCII, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, + SQL_FN_STR_LENGTH, SQL_FN_STR_LTRIM, SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, + SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, + SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, + SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, + SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, SQL_NUMERIC_FUNCTIONS, + SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, + SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, SQL_SEARCHABLE, SQL_SP_BETWEEN, + SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, SQL_SP_ISNULL, SQL_SP_LIKE, + SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, + SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, + SQL_SRJO_INTERSECT_JOIN, SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, + SQL_SRJO_RIGHT_OUTER_JOIN, SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, + SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, + SqlDataType, TypeInfoRow, catalog_column_size, format_odbc_version, parse_dotted_version, }; use super::SqliteBackend; @@ -40,35 +38,6 @@ use crate::type_conversion::{ VARCHAR_DEFAULT_COLUMN_SIZE, }; -/// Whether this driver exposes ODBC catalogs. It does not: `metadata::tables` -/// reports `TABLE_CAT` as NULL for every row, and a `catalog = "%"` enumeration -/// returns an empty result set. -/// -/// The `SQLGetInfo` specification defines five separate info types in terms of -/// this single fact — `SQL_CATALOG_NAME`, `SQL_CATALOG_TERM`, -/// `SQL_CATALOG_NAME_SEPARATOR`, `SQL_CATALOG_LOCATION` and -/// `SQL_CATALOG_USAGE` — so all five are derived from it here rather than -/// answered independently. -/// -/// That independence is what went wrong before: the driver answered -/// `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` itself -/// and let `SQL_CATALOG_TERM` and `SQL_CATALOG_NAME_SEPARATOR` fall through to -/// `stackable-odbc-core`'s defaults, which name a catalog and a separator. An -/// application was told catalogs do not exist and given their name in the same -/// breath. The spec is explicit for both: "An empty string is returned if -/// catalogs are not supported by the data source." -/// -/// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> -const SUPPORTS_CATALOGS: bool = false; - -/// Whether this driver exposes ODBC schemas. It does not: a `schema = "%"` -/// enumeration returns an empty result set and `TABLE_SCHEM` is always NULL. -/// -/// Derives `SQL_SCHEMA_TERM` and `SQL_SCHEMA_USAGE`, for the same reason -/// [`SUPPORTS_CATALOGS`] derives its five. The spec: "An empty string is -/// returned if schemas are not supported by the data source." -const SUPPORTS_SCHEMAS: bool = false; - /// ODBC function IDs for functions this driver implements. /// Used by `SQLGetFunctions` to report supported capabilities. /// Reference: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetfunctions-function> @@ -627,69 +596,7 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { } })); } - // Catalogs and schemas: every value below is derived from - // SUPPORTS_CATALOGS / SUPPORTS_SCHEMAS rather than restated, because - // the SQLGetInfo spec defines each of them in terms of that one fact. - InfoType::CatalogName => { - return Ok(InfoValue::String( - if SUPPORTS_CATALOGS { "Y" } else { "N" }.into(), - )); - } - InfoType::CatalogTerm => { - return Ok(InfoValue::String( - if SUPPORTS_CATALOGS { "catalog" } else { "" }.into(), - )); - } - InfoType::CatalogNameSeparator => { - return Ok(InfoValue::String( - if SUPPORTS_CATALOGS { "." } else { "" }.into(), - )); - } - InfoType::CatalogLocation => { - return Ok(InfoValue::U16(if SUPPORTS_CATALOGS { - SQL_CL_START - } else { - 0 - })); - } - InfoType::CatalogUsage => { - return Ok(InfoValue::U32(if SUPPORTS_CATALOGS { - SQL_CU_DML_STATEMENTS | SQL_CU_TABLE_DEFINITION | SQL_CU_INDEX_DEFINITION - } else { - 0 - })); - } - InfoType::SchemaTerm => { - return Ok(InfoValue::String( - if SUPPORTS_SCHEMAS { "schema" } else { "" }.into(), - )); - } - InfoType::SchemaUsage => { - return Ok(InfoValue::U32(if SUPPORTS_SCHEMAS { - SQL_SU_DML_STATEMENTS | SQL_SU_TABLE_DEFINITION | SQL_SU_INDEX_DEFINITION - } else { - 0 - })); - } - // Every outer-join form SQLite implements, and every relaxation of - // the ON clause the spec asks about. Core's default is 0, which - // contradicted this driver's own SQL_OUTER_JOINS = "Y". Each bit is - // exercised by `outer_join_capabilities_are_each_live_probed`. - InfoType::AlterTable => return Ok(InfoValue::U32(SQLITE_ALTER_TABLE)), - InfoType::OuterJoinCapabilities => { - return Ok(InfoValue::U32( - SQL_OJ_LEFT - | SQL_OJ_RIGHT - | SQL_OJ_FULL - | SQL_OJ_NESTED - | SQL_OJ_NOT_ORDERED - | SQL_OJ_INNER - | SQL_OJ_ALL_COMPARISON_OPS, - )); - } InfoType::IdentifierCase => return Ok(InfoValue::U16(SQL_IC_MIXED)), - InfoType::NullCollation => return Ok(InfoValue::U16(SQL_NC_LOW)), - InfoType::DefaultTxnIsolation => return Ok(InfoValue::U32(SQL_TXN_SERIALIZABLE)), // Only SERIALIZABLE. "Transactions in SQLite are SERIALIZABLE", and // READ COMMITTED and REPEATABLE READ do not exist in SQLite at all. // @@ -781,12 +688,30 @@ pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = /// - `SQL_AT_CONSTRAINT_NAME_DEFINITION`, since that `CONSTRAINT <name>` clause /// is exactly what the bit describes. /// +/// - `SQL_AT_ADD_CONSTRAINT`, which despite its name means "`ADD COLUMN` is +/// supported *with column constraints*", not table constraints. SQLite takes +/// `NOT NULL` (given a non-null default), `CHECK`, `REFERENCES` and a named +/// `CONSTRAINT` on an added column. Only `UNIQUE` and `PRIMARY KEY` are +/// refused, with "Cannot add a UNIQUE column". +/// /// Supported by SQLite but *unrepresentable*, so absent by necessity rather /// than because SQLite lacks them: unqualified `DROP COLUMN` (3.35.0+) and -/// unqualified `DROP CONSTRAINT`, for which the bitmap offers only `CASCADE` -/// and `RESTRICT` variants — and SQLite rejects both keywords, so claiming -/// either would advertise a syntax an application would send and have refused. -/// `RENAME TO` and `RENAME COLUMN` have no `SQL_AT_*` bit at all. +/// unqualified `DROP CONSTRAINT`, for which the ODBC 3.x bitmap offers only +/// `CASCADE` and `RESTRICT` variants — and SQLite rejects both keywords, so +/// claiming either would advertise a syntax an application would send and have +/// refused. `sql.h` does carry ODBC 2.0-era `SQL_AT_ADD_COLUMN` and +/// `SQL_AT_DROP_COLUMN` bits for the unqualified forms, but the ODBC 3.x +/// `SQL_ALTER_TABLE` table does not define them, and this driver reports +/// `SQL_OIC_CORE` against ODBC 3.x. `RENAME TO` and `RENAME COLUMN` have no +/// bit at all. +/// +/// Deliberately **not** claimed: the four `SQL_AT_CONSTRAINT_*` deferrability +/// bits. SQLite implements deferred constraints only inside a foreign-key +/// clause, and its parser additionally accepts `DEFERRABLE` after a `CHECK` or +/// `NOT NULL` constraint, where SQL-92 does not allow it and where it has no +/// effect. Accepting a token is not implementing the attribute, and deriving a +/// general capability from an FK-only feature plus a permissive parser is +/// exactly the overstatement these bitmaps invite. /// /// Genuinely absent: `ALTER COLUMN ... SET DEFAULT` and /// `ALTER COLUMN ... DROP DEFAULT` are not SQLite grammar. @@ -796,9 +721,24 @@ pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = /// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> /// SQLite: <https://www.sqlite.org/lang_altertable.html> +/// `SQL_OUTER_JOIN_CAPABILITIES` (115) — every outer-join form SQLite +/// implements, and every relaxation of the `ON` clause the bitmap asks about. +/// +/// Each bit is proved by executing the join it describes against the bundled +/// library in `outer_join_capabilities_are_each_live_probed`; `RIGHT` and +/// `FULL` arrived in SQLite 3.39.0. +pub(crate) const SQLITE_OUTER_JOIN_CAPABILITIES: u32 = SQL_OJ_LEFT + | SQL_OJ_RIGHT + | SQL_OJ_FULL + | SQL_OJ_NESTED + | SQL_OJ_NOT_ORDERED + | SQL_OJ_INNER + | SQL_OJ_ALL_COMPARISON_OPS; + pub(crate) const SQLITE_ALTER_TABLE: u32 = SQL_AT_ADD_COLUMN_SINGLE | SQL_AT_ADD_COLUMN_DEFAULT | SQL_AT_ADD_COLUMN_COLLATION + | SQL_AT_ADD_CONSTRAINT | SQL_AT_ADD_TABLE_CONSTRAINT | SQL_AT_CONSTRAINT_NAME_DEFINITION; @@ -1101,23 +1041,23 @@ mod tests { DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_AT_DROP_COLUMN_CASCADE, SQL_AT_DROP_COLUMN_DEFAULT, SQL_AT_DROP_COLUMN_RESTRICT, SQL_AT_DROP_TABLE_CONSTRAINT_CASCADE, SQL_AT_DROP_TABLE_CONSTRAINT_RESTRICT, - SQL_AT_SET_COLUMN_DEFAULT, SQL_CA1_NEXT, SQL_CB_PRESERVE, SQL_DRIVER_ODBC_VER_STRING, - SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, - SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, - SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, SQL_FN_STR_CHARACTER_LENGTH, - SQL_FN_STR_DIFFERENCE, SQL_FN_STR_INSERT, SQL_FN_STR_LEFT, SQL_FN_STR_LOCATE, - SQL_FN_STR_LOCATE_2, SQL_FN_STR_POSITION, SQL_FN_STR_REPEAT, SQL_FN_STR_RIGHT, - SQL_FN_STR_SPACE, SQL_FN_TD_DAYNAME, SQL_FN_TD_DAYOFMONTH, SQL_FN_TD_EXTRACT, - SQL_FN_TD_MONTH, SQL_FN_TD_MONTHNAME, SQL_FN_TD_QUARTER, SQL_FN_TD_TIMESTAMPADD, - SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_YEAR, SQL_GB_NO_RELATION, SQL_GD_ANY_COLUMN, - SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_INSENSITIVE, SQL_MAX_CURSOR_NAME_LEN, - SQL_NC_LOW, SQL_OIC_CORE, SQL_SC_SQL92_ENTRY, SQL_SO_FORWARD_ONLY, SQL_SP_MATCH_FULL, - SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, SQL_SP_MATCH_UNIQUE_PARTIAL, - SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, SQL_SQ_COMPARISON, - SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, SQL_SQ_QUANTIFIED, - SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, SQL_TXN_READ_COMMITTED, - SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SQL_U_UNION, - SQL_U_UNION_ALL, + SQL_AT_SET_COLUMN_DEFAULT, SQL_CA1_NEXT, SQL_CB_PRESERVE, SQL_CN_ANY, + SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, + SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, + SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, + SQL_FN_STR_CHARACTER_LENGTH, SQL_FN_STR_DIFFERENCE, SQL_FN_STR_INSERT, SQL_FN_STR_LEFT, + SQL_FN_STR_LOCATE, SQL_FN_STR_LOCATE_2, SQL_FN_STR_POSITION, SQL_FN_STR_REPEAT, + SQL_FN_STR_RIGHT, SQL_FN_STR_SPACE, SQL_FN_TD_DAYNAME, SQL_FN_TD_DAYOFMONTH, + SQL_FN_TD_EXTRACT, SQL_FN_TD_MONTH, SQL_FN_TD_MONTHNAME, SQL_FN_TD_QUARTER, + SQL_FN_TD_TIMESTAMPADD, SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_YEAR, SQL_GB_NO_RELATION, + SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_INSENSITIVE, + SQL_MAX_CURSOR_NAME_LEN, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_OIC_CORE, SQL_SO_FORWARD_ONLY, + SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, + SQL_SP_MATCH_UNIQUE_PARTIAL, SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, + SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, + SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, + SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, + SQL_TXN_SERIALIZABLE, SQL_U_UNION, SQL_U_UNION_ALL, }; enum Expected { @@ -1134,13 +1074,14 @@ mod tests { (InfoType::DriverOdbcVer, Expected::Str(SQL_DRIVER_ODBC_VER_STRING)), (InfoType::SearchPatternEscape, Expected::Str("\\")), (InfoType::IdentifierQuoteChar, Expected::Str("\"")), - // Empty, not "catalog": derived from SUPPORTS_CATALOGS. The spec + // Empty, not "catalog": core derives this from + // Backend::supports_catalogs, which this driver answers false. The spec // requires an empty string when catalogs are unsupported, which // SQL_CATALOG_NAME = "N" declares. (InfoType::CatalogTerm, Expected::Str("")), - // Empty, not "schema": derived from SUPPORTS_SCHEMAS, same spec rule. + // Empty, not "schema": derived from Backend::supports_schemas. (InfoType::SchemaTerm, Expected::Str("")), - // Empty, not ".": derived from SUPPORTS_CATALOGS, same spec rule. + // Empty, not ".": same hook, same spec rule. (InfoType::CatalogNameSeparator, Expected::Str("")), (InfoType::ColumnAlias, Expected::Str("Y")), (InfoType::OrderByColumnsInSelect, Expected::Str("N")), @@ -1172,6 +1113,10 @@ mod tests { (InfoType::MaxCatalogNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), (InfoType::MaxTableNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), (InfoType::NullCollation, Expected::U16(SQL_NC_LOW)), + // These three were never in this snapshot: core invented them until it + // made them required Backend methods, so nothing here asserted them. + (InfoType::CorrelationName, Expected::U16(SQL_CN_ANY)), + (InfoType::NonNullableColumns, Expected::U16(SQL_NNC_NON_NULL)), (InfoType::MaxColumnsInGroupBy, Expected::U16(0)), (InfoType::MaxColumnsInIndex, Expected::U16(0)), (InfoType::MaxColumnsInOrderBy, Expected::U16(0)), @@ -1207,7 +1152,11 @@ mod tests { (InfoType::OuterJoinCapabilities, Expected::U32( SQL_OJ_LEFT | SQL_OJ_RIGHT | SQL_OJ_FULL | SQL_OJ_NESTED | SQL_OJ_NOT_ORDERED | SQL_OJ_INNER | SQL_OJ_ALL_COMPARISON_OPS)), - (InfoType::SqlConformance, Expected::U32(SQL_SC_SQL92_ENTRY)), + // 0, not SQL_SC_SQL92_ENTRY: entry level requires + // SQL_GB_GROUP_BY_EQUALS_SELECT, and SQLite accepts a bare + // non-aggregated column absent from GROUP BY. See + // SqliteBackend::sql_conformance. + (InfoType::SqlConformance, Expected::U32(0)), (InfoType::OdbcInterfaceConformance, Expected::U32(SQL_OIC_CORE)), (InfoType::AsyncMode, Expected::U32(SQL_AM_NONE)), (InfoType::AsyncDbcFunctions, Expected::U32(0)), @@ -1308,6 +1257,39 @@ mod tests { /// If a future `rusqlite`/`libsqlite3-sys` bump silently drops one of /// these compile flags, this test fails with a clear "no such function" /// error instead of the bitmap silently overclaiming forever. + /// SQLite's `GROUP BY` is unrelated to the select list, which is what + /// `SQL_GB_NO_RELATION` means and what rules out the SQL-92 entry level. + /// + /// The spec: "a SQL-92 Entry level-conformant driver will always return the + /// SQL_GB_GROUP_BY_EQUALS_SELECT option as supported." SQLite does the + /// opposite in both directions, so `SqliteBackend::sql_conformance` claims + /// no level rather than one this contradicts. + #[test] + fn group_by_is_unrelated_to_the_select_list() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE gb (a INTEGER, b TEXT); + INSERT INTO gb VALUES (1, 'x'), (1, 'y'), (2, 'z');", + ) + .unwrap(); + + // A non-aggregated column absent from GROUP BY: rejected by anything + // stricter than SQL_GB_NO_RELATION. + conn.prepare("SELECT a, b, count(*) FROM gb GROUP BY a") + .expect("SQLite accepts a bare non-aggregated column"); + + // And the converse: a GROUP BY column absent from the select list. + conn.prepare("SELECT count(*) FROM gb GROUP BY b") + .expect("SQLite accepts a GROUP BY column absent from the select list"); + + assert_eq!(SqliteBackend::group_by(), SQL_GB_NO_RELATION); + assert_eq!( + SqliteBackend::sql_conformance(), + 0, + "SQL_GB_NO_RELATION rules out the SQL-92 entry level" + ); + } + /// The five catalog info types and the two schema info types must agree /// with each other. This is the test the previous arrangement lacked: /// `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` said @@ -1316,7 +1298,8 @@ mod tests { /// one, and nothing tied the two groups together. /// /// Asserts the spec's rule, not the current values, so it keeps holding if - /// [`SUPPORTS_CATALOGS`] or [`SUPPORTS_SCHEMAS`] ever flips. + /// [`SqliteBackend::supports_catalogs`] or + /// [`SqliteBackend::supports_schemas`] ever flips. #[test] fn catalog_and_schema_info_types_agree_with_each_other() { let get = |t: InfoType| sqlite_get_info(t).expect("info type answered"); @@ -1326,8 +1309,9 @@ mod tests { InfoValue::String(ref s) if s == "Y" ); assert_eq!( - catalogs_supported, SUPPORTS_CATALOGS, - "SQL_CATALOG_NAME must follow SUPPORTS_CATALOGS" + catalogs_supported, + SqliteBackend::supports_catalogs(), + "SQL_CATALOG_NAME must follow Backend::supports_catalogs" ); if catalogs_supported { @@ -1360,7 +1344,7 @@ mod tests { ); } - if SUPPORTS_SCHEMAS { + if SqliteBackend::supports_schemas() { assert_ne!(get(InfoType::SchemaTerm), InfoValue::String(String::new())); } else { assert_eq!( From 6c783736f48e0469127d7c2436afe41dd11e7320 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 20:04:42 +0200 Subject: [PATCH 16/50] fix: report that SQLite enforces referential integrity SQL_INTEGRITY reported "N", inherited from stackable-odbc-core's default. SQLite implements the entire Integrity Enhancement Facility -- PRIMARY KEY, UNIQUE, NOT NULL, CHECK, DEFAULT, and FOREIGN KEY with referential actions -- and this build enforces every part of it. The earlier justification for "N" was that SQLite leaves foreign keys off unless an application turns them on. That is true of plain SQLite and false here: the bundled library is compiled with SQLITE_DEFAULT_FOREIGN_KEYS, so PRAGMA foreign_keys is already on when a connection opens, and an orphan insert fails without the driver doing anything. Because that is a property of this particular build rather than of SQLite, the new test asserts the pragma directly as well as exercising each constraint and an ON DELETE CASCADE. Dropping rusqlite's bundled feature for a system SQLite would turn enforcement off, and this fails rather than letting the claim go quietly false. SQLForeignKeys is genuinely implemented over PRAGMA foreign_key_list, so an application that acts on the "Y" finds the metadata it then asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 11 ++++++ src/backend/info.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 271f360..6014bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 advertised it and implemented nothing, so the driver reported that it destroyed cursors on commit while in fact preserving them. +- `SQL_INTEGRITY` now reports `"Y"` instead of `"N"`. SQLite implements the + whole Integrity Enhancement Facility — `PRIMARY KEY`, `UNIQUE`, `NOT NULL`, + `CHECK`, `DEFAULT`, and `FOREIGN KEY` with referential actions — and this + build enforces all of it: the bundled library is compiled with + `SQLITE_DEFAULT_FOREIGN_KEYS`, so `PRAGMA foreign_keys` is on before the + driver does anything. `"N"` was `stackable-odbc-core`'s default, and the + earlier justification for keeping it — that SQLite leaves foreign keys off + unless asked — is not true of this build. A test asserts each constraint is + actually enforced, so dropping `rusqlite`'s `bundled` feature for a system + SQLite fails loudly rather than making the claim quietly false. + - `SQL_SQL_CONFORMANCE` now reports `0` — no SQL-92 level claimed — instead of `SQL_SC_SQL92_ENTRY`. That value came from a `stackable-odbc-core` default rather than any assessment of SQLite, and it contradicted this driver's own diff --git a/src/backend/info.rs b/src/backend/info.rs index cef8d45..a5c7810 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -597,6 +597,26 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { })); } InfoType::IdentifierCase => return Ok(InfoValue::U16(SQL_IC_MIXED)), + // "Y": SQLite implements the whole Integrity Enhancement Facility -- + // PRIMARY KEY, UNIQUE, NOT NULL, CHECK, DEFAULT and FOREIGN KEY with + // referential actions -- and this build enforces all of it. Core + // defaults to "N", which is the right conservative answer for a data + // source without it and the wrong one here. + // + // Referential integrity in particular is enforced by construction, not + // by chance: the bundled library is compiled with + // SQLITE_DEFAULT_FOREIGN_KEYS, so `PRAGMA foreign_keys` is already on + // when a connection opens. Plain SQLite defaults it off for backward + // compatibility, so this claim is a property of *this* build. + // `integrity_enhancement_facility_is_actually_enforced` asserts that, + // and fails loudly if a dependency change ever takes the compile + // option away -- switching `rusqlite` off `bundled` to a system SQLite + // would. + // + // `SQLForeignKeys` is genuinely implemented (`metadata::foreign_keys`, + // over `PRAGMA foreign_key_list`), so an application that acts on this + // "Y" finds the metadata it then asks for. + InfoType::Integrity => return Ok(InfoValue::String("Y".into())), // Only SERIALIZABLE. "Transactions in SQLite are SERIALIZABLE", and // READ COMMITTED and REPEATABLE READ do not exist in SQLite at all. // @@ -1092,7 +1112,9 @@ mod tests { (InfoType::DataSourceReadOnly, Expected::Str("N")), (InfoType::AccessibleTables, Expected::Str("Y")), (InfoType::AccessibleProcedures, Expected::Str("N")), - (InfoType::Integrity, Expected::Str("N")), + // "Y", not "N": SQLite implements and enforces the Integrity + // Enhancement Facility. See the arm in sqlite_get_info. + (InfoType::Integrity, Expected::Str("Y")), (InfoType::SpecialCharacters, Expected::Str("")), (InfoType::XopenCliYear, Expected::Str("1995")), (InfoType::CollationSeq, Expected::Str("")), @@ -1257,6 +1279,64 @@ mod tests { /// If a future `rusqlite`/`libsqlite3-sys` bump silently drops one of /// these compile flags, this test fails with a clear "no such function" /// error instead of the bitmap silently overclaiming forever. + /// Every part of the Integrity Enhancement Facility this driver claims via + /// `SQL_INTEGRITY = "Y"`, proved by making the bundled library reject a + /// violation rather than by reading its documentation. + /// + /// Referential integrity is the fragile one. Plain SQLite defaults + /// `PRAGMA foreign_keys` to off for backward compatibility; this build is + /// compiled with `SQLITE_DEFAULT_FOREIGN_KEYS`, so it is on before the + /// driver does anything. Dropping `rusqlite`'s `bundled` feature for a + /// system SQLite would silently turn enforcement off and make the claim + /// false, so the pragma is asserted directly. + #[test] + fn integrity_enhancement_facility_is_actually_enforced() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + + let fk_on: i64 = conn + .query_row("PRAGMA foreign_keys", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + fk_on, 1, + "foreign keys are off, so SQL_INTEGRITY = \"Y\" is a false claim -- \ + this build should carry SQLITE_DEFAULT_FOREIGN_KEYS" + ); + + conn.execute_batch( + "CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child (pid INTEGER REFERENCES parent(id) ON DELETE CASCADE); + CREATE TABLE con (v INTEGER CHECK (v > 0), u INTEGER UNIQUE, n INTEGER NOT NULL DEFAULT 7);", + ) + .unwrap(); + + for (feature, sql) in [ + ("FOREIGN KEY", "INSERT INTO child VALUES (999)"), + ("CHECK", "INSERT INTO con (v, n) VALUES (-1, 1)"), + ("NOT NULL", "INSERT INTO con (v, n) VALUES (1, NULL)"), + ( + "UNIQUE", + "INSERT INTO con (u, n) VALUES (1, 1); INSERT INTO con (u, n) VALUES (1, 2)", + ), + ] { + assert!( + conn.execute_batch(sql).is_err(), + "{feature} is not enforced, so SQL_INTEGRITY = \"Y\" overstates\n {sql}" + ); + } + + // DEFAULT, and a referential action rather than mere rejection. + conn.execute_batch("INSERT INTO con (v, u) VALUES (1, 42)") + .expect("DEFAULT should supply the NOT NULL column"); + conn.execute_batch("INSERT INTO parent VALUES (1); INSERT INTO child VALUES (1);") + .unwrap(); + conn.execute_batch("DELETE FROM parent WHERE id = 1") + .unwrap(); + let orphans: i64 = conn + .query_row("SELECT count(*) FROM child", [], |r| r.get(0)) + .unwrap(); + assert_eq!(orphans, 0, "ON DELETE CASCADE did not cascade"); + } + /// SQLite's `GROUP BY` is unrelated to the select list, which is what /// `SQL_GB_NO_RELATION` means and what rules out the SQL-92 entry level. /// From 44ea671e237877d042ab7039ddb9fb7cff82e65c Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 20:17:55 +0200 Subject: [PATCH 17/50] fix: enable foreign key enforcement on every connection connect now issues PRAGMA foreign_keys = ON, so that SQL_INTEGRITY = "Y" is true by construction rather than by build configuration. SQLite defaults the pragma off for backward compatibility. Enforcement worked only because the bundled library is compiled with SQLITE_DEFAULT_FOREIGN_KEYS, which is a property of one dependency's build and not of SQLite: dropping rusqlite's bundled feature for a system library would have turned referential integrity off while the driver went on advertising it. On the current build this changes no behaviour, which is the point -- the integration suite is unchanged at 46 passing. It removes the coupling between an advertised capability and a dependency's compile flags. The enforcement test now goes through Backend::connect rather than opening a rusqlite connection directly. A raw connection would only re-test the dependency's build configuration, which is exactly what this stops relying on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 22 ++++++++++++++-------- src/backend.rs | 20 ++++++++++++++++++++ src/backend/info.rs | 25 +++++++++++++++---------- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6014bc4..d5220bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,16 +27,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 advertised it and implemented nothing, so the driver reported that it destroyed cursors on commit while in fact preserving them. +- Connections now enable foreign key enforcement: `connect` issues + `PRAGMA foreign_keys = ON`. SQLite leaves it off for backward compatibility, + and the bundled library only happened to be compiled with + `SQLITE_DEFAULT_FOREIGN_KEYS` — a property of one dependency's build rather + than of SQLite. On the current build this changes nothing; it stops + referential integrity from turning itself off if that dependency ever + changes. + - `SQL_INTEGRITY` now reports `"Y"` instead of `"N"`. SQLite implements the whole Integrity Enhancement Facility — `PRIMARY KEY`, `UNIQUE`, `NOT NULL`, - `CHECK`, `DEFAULT`, and `FOREIGN KEY` with referential actions — and this - build enforces all of it: the bundled library is compiled with - `SQLITE_DEFAULT_FOREIGN_KEYS`, so `PRAGMA foreign_keys` is on before the - driver does anything. `"N"` was `stackable-odbc-core`'s default, and the - earlier justification for keeping it — that SQLite leaves foreign keys off - unless asked — is not true of this build. A test asserts each constraint is - actually enforced, so dropping `rusqlite`'s `bundled` feature for a system - SQLite fails loudly rather than making the claim quietly false. + `CHECK`, `DEFAULT`, and `FOREIGN KEY` with referential actions — and, with + the pragma above, the driver enforces all of it. `"N"` was + `stackable-odbc-core`'s default, and the earlier justification for keeping + it — that SQLite leaves foreign keys off unless asked — no longer applies now + that the driver asks. A test exercises each constraint and an + `ON DELETE CASCADE` through `connect`. - `SQL_SQL_CONFORMANCE` now reports `0` — no SQL-92 level claimed — instead of `SQL_SC_SQL92_ENTRY`. That value came from a `stackable-odbc-core` default diff --git a/src/backend.rs b/src/backend.rs index 13555c2..ef95fc5 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -227,6 +227,26 @@ impl Backend for SqliteBackend { fn connect(params: &ConnectParams) -> Result<SqliteConnection, SqliteError> { let p = types::connect_params::SqliteConnectParams::try_from(params)?; let conn = rusqlite::Connection::open(p.database()).map_err(map_sqlite_error)?; + + // Enforce foreign keys explicitly, so that `SQL_INTEGRITY = "Y"` is + // true by construction rather than by build configuration. + // + // SQLite defaults this off for backward compatibility. The bundled + // library happens to be compiled with `SQLITE_DEFAULT_FOREIGN_KEYS`, + // so it was already on — but that is a property of one dependency's + // build, not of SQLite, and dropping `rusqlite`'s `bundled` feature + // for a system library would silently turn referential integrity off + // while the driver went on advertising it. + // + // The pragma is per-connection and a no-op inside a transaction; here + // there is not one yet. `PRAGMA foreign_keys` is also a no-op rather + // than an error on a build compiled with `SQLITE_OMIT_FOREIGN_KEY`, + // which is why `Backend::connect` cannot treat success as proof — + // `integrity_enhancement_facility_is_actually_enforced` reads the + // value back through this function. + conn.execute_batch("PRAGMA foreign_keys = ON") + .map_err(map_sqlite_error)?; + Ok(SqliteConnection { conn: Mutex::new(conn), manual_commit: std::sync::atomic::AtomicBool::new(false), diff --git a/src/backend/info.rs b/src/backend/info.rs index a5c7810..bd83d4a 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -1058,8 +1058,8 @@ mod tests { } use super::*; use stackable_odbc_core::types::{ - DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_AT_DROP_COLUMN_CASCADE, - SQL_AT_DROP_COLUMN_DEFAULT, SQL_AT_DROP_COLUMN_RESTRICT, + ConnectParams, DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, + SQL_AT_DROP_COLUMN_CASCADE, SQL_AT_DROP_COLUMN_DEFAULT, SQL_AT_DROP_COLUMN_RESTRICT, SQL_AT_DROP_TABLE_CONSTRAINT_CASCADE, SQL_AT_DROP_TABLE_CONSTRAINT_RESTRICT, SQL_AT_SET_COLUMN_DEFAULT, SQL_CA1_NEXT, SQL_CB_PRESERVE, SQL_CN_ANY, SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, @@ -1284,22 +1284,27 @@ mod tests { /// violation rather than by reading its documentation. /// /// Referential integrity is the fragile one. Plain SQLite defaults - /// `PRAGMA foreign_keys` to off for backward compatibility; this build is - /// compiled with `SQLITE_DEFAULT_FOREIGN_KEYS`, so it is on before the - /// driver does anything. Dropping `rusqlite`'s `bundled` feature for a - /// system SQLite would silently turn enforcement off and make the claim - /// false, so the pragma is asserted directly. + /// `PRAGMA foreign_keys` to off for backward compatibility, so + /// [`SqliteBackend::connect`] turns it on explicitly rather than relying on + /// the bundled library's `SQLITE_DEFAULT_FOREIGN_KEYS`. + /// + /// This goes through `connect` rather than opening a `rusqlite` connection + /// directly, because `connect` is where the guarantee lives — a raw + /// connection would only re-test the dependency's build configuration, + /// which is exactly what the driver stopped depending on. #[test] fn integrity_enhancement_facility_is_actually_enforced() { - let conn = rusqlite::Connection::open_in_memory().unwrap(); + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let sqlite_conn = SqliteBackend::connect(&params).expect("connect"); + let conn = sqlite_conn.conn.lock().expect("lock"); let fk_on: i64 = conn .query_row("PRAGMA foreign_keys", [], |r| r.get(0)) .unwrap(); assert_eq!( fk_on, 1, - "foreign keys are off, so SQL_INTEGRITY = \"Y\" is a false claim -- \ - this build should carry SQLITE_DEFAULT_FOREIGN_KEYS" + "SqliteBackend::connect did not enable foreign keys, so \ + SQL_INTEGRITY = \"Y\" is a false claim" ); conn.execute_batch( From 8422556f87910ce8a1fc15421207a710307a5eff Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 20:41:50 +0200 Subject: [PATCH 18/50] fix: translate the bare-keyword date/time {fn} escapes SQL_TIMEDATE_FUNCTIONS advertised SQL_FN_TD_CURRENT_DATE, _CURRENT_TIME and _CURRENT_TIMESTAMP while the escape dialect deliberately did not translate them, because SQLite spells all three as bare keywords and a name-only remap cannot drop the trailing () the ODBC escape always carries. The consequence was not a missing nicety: {fn CURRENT_DATE()} reached SQLite as CURRENT_DATE(), which is a syntax error, so the driver advertised three functions an application could not use. The module doc recorded the limitation without following it through to the bitmap that promised them. stackable-odbc-core's new EscapeDialect::rewrite_scalar_fn replaces the whole escape, so a zero-argument call can emit a bare keyword. A call with arguments declines the rewrite rather than discarding them: {fn CURRENT_DATE(x)} has no SQLite spelling, and silently dropping x would be worse than letting it fail. The new FFI test executes each escape and checks the returned shape. It fails without the rewrite, with the escape's parentheses reaching SQLite -- verified by reverting the dialect and re-running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 10 +++++ src/escape_dialect.rs | 72 ++++++++++++++++++++++++++++++++---- src/ffi_integration_tests.rs | 58 +++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5220bf..4e0d4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `{fn CURRENT_DATE()}`, `{fn CURRENT_TIME()}` and `{fn CURRENT_TIMESTAMP()}` + now execute. `SQL_TIMEDATE_FUNCTIONS` advertised all three, but nothing + translated them: SQLite spells them as bare keywords, `SELECT CURRENT_DATE();` + is a syntax error, and a name-only remap cannot drop the trailing `()` the + ODBC escape always carries — so each reached SQLite as `CURRENT_DATE()` and + failed to prepare. The driver was advertising three functions an application + could not use. `stackable-odbc-core`'s new + `EscapeDialect::rewrite_scalar_fn` replaces the whole escape, which is what + emitting a bare keyword requires. + - `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR` and `SQL_SCHEMA_TERM` now report empty strings instead of `"catalog"`, `"."` and `"schema"`. The `SQLGetInfo` specification requires an empty string from all three when the diff --git a/src/escape_dialect.rs b/src/escape_dialect.rs index 1709d0a..f56fb03 100644 --- a/src/escape_dialect.rs +++ b/src/escape_dialect.rs @@ -35,13 +35,21 @@ //! `abs()`, `sign()`, `round()`, `ifnull()`, so they pass through //! unchanged (`None`). SQLite has `ifnull()` natively, so no substitution //! is needed for `SQL_FN_SYS_IFNULL`. +//! +//! Names handled by [`rewrite_scalar_fn`] rather than the remap table: +//! //! - `SQL_FN_TD_CURRENT_DATE` / `SQL_FN_TD_CURRENT_TIME` / //! `SQL_FN_TD_CURRENT_TIMESTAMP`: SQLite's `CURRENT_DATE` / `CURRENT_TIME` //! / `CURRENT_TIMESTAMP` are bare keywords, not callable functions. //! `SELECT CURRENT_DATE();` is a syntax error (confirmed live: "near '(': //! syntax error"). The ODBC escape always includes `()` (e.g. -//! `{fn CURRENT_DATE()}`), and the translator appends whatever follows the -//! name verbatim, so no name-only rename can drop that trailing `()`. +//! `{fn CURRENT_DATE()}`), and a name-only rename appends whatever follows +//! the name verbatim, so it cannot drop that trailing `()`. +//! +//! These three were advertised in `SQL_TIMEDATE_FUNCTIONS` while no +//! translation existed for them, so `{fn CURRENT_DATE()}` reached SQLite as +//! `CURRENT_DATE()` and failed to prepare. `rewrite_scalar_fn` replaces the +//! whole escape, which is what emitting a bare keyword requires. use stackable_odbc_core::escape::EscapeDialect; /// Remap an ODBC `{fn NAME(...)}` scalar-function name to SQLite's spelling. @@ -63,6 +71,32 @@ pub(crate) fn remap_scalar_fn(name: &str) -> Option<&'static str> { } } +/// Rewrite a whole `{fn NAME(args)}` escape, for the calls a name swap cannot +/// express. +/// +/// Only the three bare-keyword date/time forms need this: SQLite spells them +/// `CURRENT_DATE` / `CURRENT_TIME` / `CURRENT_TIMESTAMP` with no parentheses, +/// and `SELECT CURRENT_DATE();` is a syntax error. Returning the keyword alone +/// replaces the escape including its `()`. +/// +/// Everything else returns `None` and falls back to [`remap_scalar_fn`]. The +/// argument text is checked rather than ignored: `{fn CURRENT_DATE(x)}` is not +/// a call SQLite has any spelling for, so it is left alone to fail as the +/// malformed call it is, instead of being silently rewritten to a keyword that +/// discards `x`. +pub(crate) fn rewrite_scalar_fn(name: &str, args: &str) -> Option<String> { + if !args.trim().is_empty() { + return None; + } + match name.to_ascii_uppercase().as_str() { + // SQL_FN_TD_CURRENT_DATE / _CURRENT_TIME / _CURRENT_TIMESTAMP + "CURRENT_DATE" => Some("CURRENT_DATE".to_string()), + "CURRENT_TIME" => Some("CURRENT_TIME".to_string()), + "CURRENT_TIMESTAMP" => Some("CURRENT_TIMESTAMP".to_string()), + _ => None, + } +} + /// SQLite has no date/time/timestamp storage classes, a date/time value is /// just quoted text, so `{d/t/ts '...'}` render to the bare string literal /// with no leading type keyword. @@ -76,6 +110,7 @@ pub(crate) fn dialect() -> EscapeDialect { EscapeDialect { identifier_quotes: &[('"', '"'), ('`', '`'), ('[', ']')], remap_scalar_fn, + rewrite_scalar_fn, render_date: render_bare, render_time: render_bare, render_timestamp: render_bare, @@ -153,20 +188,41 @@ mod tests { assert_eq!(remap_scalar_fn("SIGN"), None); } - // Deliberately NOT remapped despite being advertised (see module doc). + // The three bare-keyword forms are handled by rewrite_scalar_fn, not by + // the name-only remap table, which cannot drop the escape's trailing `()`. #[test] - fn current_date_not_remapped() { + fn current_date_is_not_a_name_only_remap() { assert_eq!(remap_scalar_fn("CURRENT_DATE"), None); + assert_eq!(remap_scalar_fn("CURRENT_TIME"), None); + assert_eq!(remap_scalar_fn("CURRENT_TIMESTAMP"), None); } #[test] - fn current_time_not_remapped() { - assert_eq!(remap_scalar_fn("CURRENT_TIME"), None); + fn bare_keyword_datetime_forms_are_rewritten_without_parentheses() { + for name in ["CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP"] { + assert_eq!(rewrite_scalar_fn(name, ""), Some(name.to_string())); + // Case-insensitive, like the remap table. + assert_eq!( + rewrite_scalar_fn(&name.to_ascii_lowercase(), ""), + Some(name.to_string()) + ); + } } + /// A call with arguments is left alone rather than rewritten to a keyword + /// that would silently discard them. #[test] - fn current_timestamp_not_remapped() { - assert_eq!(remap_scalar_fn("CURRENT_TIMESTAMP"), None); + fn bare_keyword_rewrite_declines_a_call_with_arguments() { + assert_eq!(rewrite_scalar_fn("CURRENT_DATE", "x"), None); + assert_eq!(rewrite_scalar_fn("CURRENT_TIMESTAMP", "1, 2"), None); + } + + /// Everything else falls through to the remap table. + #[test] + fn rewrite_declines_names_the_remap_table_owns() { + for name in ["UCASE", "SUBSTRING", "NOW", "CURDATE", "ABS"] { + assert_eq!(rewrite_scalar_fn(name, ""), None); + } } #[test] diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 64b7a31..0c5d432 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -4515,6 +4515,64 @@ fn escape_fn_curdate_executes_as_sqlite_date() { } } +/// The three bare-keyword date/time escapes must reach SQLite without their +/// parentheses and actually execute. +/// +/// `SQL_TIMEDATE_FUNCTIONS` advertises `SQL_FN_TD_CURRENT_DATE`, +/// `_CURRENT_TIME` and `_CURRENT_TIMESTAMP`, but nothing translated them: +/// `{fn CURRENT_DATE()}` reached SQLite as `CURRENT_DATE()`, which is a syntax +/// error, so the driver advertised three functions an application could not +/// use. `EscapeDialect::rewrite_scalar_fn` replaces the whole escape, which is +/// what emitting a bare keyword requires. +/// +/// Only the shape is asserted -- these are clock values. +#[test] +fn escape_bare_keyword_datetime_fns_execute() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + // (escape, expected length, expected separators at their positions) + for (sql, len, seps) in [ + ("SELECT {fn CURRENT_DATE()}", 10, vec![(4, b'-'), (7, b'-')]), + ("SELECT {fn CURRENT_TIME()}", 8, vec![(2, b':'), (5, b':')]), + ( + "SELECT {fn CURRENT_TIMESTAMP()}", + 19, + vec![(4, b'-'), (7, b'-'), (10, b' '), (13, b':'), (16, b':')], + ), + ] { + assert_eq!( + exec_direct(stmt, sql), + SqlReturn::SUCCESS, + "{sql} failed to translate -- the escape's trailing () most \ + likely reached SQLite" + ); + assert_eq!( + ffi::fetch::sql_fetch::<SqliteBackend>(stmt), + SqlReturn::SUCCESS + ); + + let value = fetch_string_col(stmt, 1); + assert_eq!(value.len(), len, "{sql} returned {value:?}"); + for (idx, sep) in seps { + assert_eq!( + value.as_bytes()[idx], + sep, + "{sql} missing separator at {idx} in {value:?}" + ); + } + + assert_eq!( + ffi::cursor::sql_close_cursor::<SqliteBackend>(stmt), + SqlReturn::SUCCESS + ); + } + + cleanup(env, conn, stmt); + } +} + /// `{fn NOW()}` must be remapped to SQLite's zero-argument `datetime()` and /// actually execute against the database. As with CURDATE, only the ISO /// `YYYY-MM-DD HH:MM:SS` shape is checked (length 19, dashes/colons/space at From 2d7b68e7e36d153e04fafe3f1703694b98453061 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 21:01:02 +0200 Subject: [PATCH 19/50] feat!: state the nine data-source facts core stopped guessing stackable-odbc-core replaced its hand audit of default_get_info with a test that asks, of every info type, whether the answer moves when the backend does. Nine more values were statements about the data source that core had no way to know, and are now required Backend methods. Eight carry values this driver was already reporting, verified against the bundled library rather than moved on trust: column_alias, concat_null_behavior (SQL_CB_NULL -- 'a' || NULL is NULL), union_support, convert_functions, order_by_columns_in_select, accessible_tables, data_source_read_only and search_pattern_escape. SQL_SUBQUERIES changed. Core's default claimed SQL_SQ_QUANTIFIED, while this driver's SQL_SQL92_PREDICATES already excluded SQL_SP_QUANTIFIED_COMPARISON with a test recording that `< ALL`, `< ANY` and `< SOME` do not parse. The same capability was advertised by one info type and denied by another, and the advertised half is the one a BI tool acts on: it would push down a predicate SQLite rejects. The bit is dropped, and the new test probes all four claimed forms and all three unclaimed quantified spellings. accessible_tables is the one value here that describes the connected principal rather than the SQL dialect. "Y" guarantees SELECT on every table SQLTables returns, which is safe to claim only because SQLite has no principal and no per-table permissions -- opening the file is the whole access check. Also finishes SQL_KEYWORDS, which core answers with an empty string: that claims SQLite has no keywords of its own. It has AUTOINCREMENT, PRAGMA, VACUUM, GLOB, REGEXP and more, and applications read the value to decide what to quote. The list is read out of the linked library through sqlite3_keyword_count / sqlite3_keyword_name rather than transcribed from the documentation, so it describes the SQLite the driver links rather than the one an author was reading about, and filtered against the ODBC reserved list the spec defines this value as excluding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 23 ++ src/backend.rs | 68 +++++- src/backend/info.rs | 501 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 567 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0d4b4..dfe239c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 an added column; only `UNIQUE` and `PRIMARY KEY` are refused. The bit was unavailable when this driver first set the bitmap. +- `SQL_SUBQUERIES`, `SQL_COLUMN_ALIAS`, `SQL_CONCAT_NULL_BEHAVIOR`, + `SQL_UNION`, `SQL_CONVERT_FUNCTIONS`, `SQL_ORDER_BY_COLUMNS_IN_SELECT`, + `SQL_ACCESSIBLE_TABLES`, `SQL_DATA_SOURCE_READ_ONLY` and + `SQL_SEARCH_PATTERN_ESCAPE` are now stated by this driver rather than + inherited from `stackable-odbc-core`, which had no way to know most of them. + Every value was verified against the bundled library; only `SQL_SUBQUERIES` + changed (see `Fixed`). + - `SQL_GROUP_BY`, `SQL_NULL_COLLATION`, `SQL_CORRELATION_NAME`, `SQL_NON_NULLABLE_COLUMNS`, `SQL_EXPRESSIONS_IN_ORDERBY`, `SQL_TIMEDATE_ADD_INTERVALS` and `SQL_TIMEDATE_DIFF_INTERVALS` are now stated @@ -96,6 +104,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `SQL_SUBQUERIES` no longer claims `SQL_SQ_QUANTIFIED`. `< ALL`, `< ANY` and + `< SOME` are all syntax errors in SQLite, which this driver already recorded + by excluding `SQL_SP_QUANTIFIED_COMPARISON` from `SQL_SQL92_PREDICATES` — so + the same capability was denied by one info type and advertised by another, + the advertised half coming from a `stackable-odbc-core` default. A tool + reading `SQL_SUBQUERIES` would have pushed down a predicate SQLite rejects. + +- `SQL_KEYWORDS` now lists SQLite's own keywords instead of an empty string. + The list is read out of the linked library through `sqlite3_keyword_count` / + `sqlite3_keyword_name` and filtered against the ODBC reserved list, which the + specification defines this value as excluding. An empty list claimed SQLite + has no keywords of its own — it has `AUTOINCREMENT`, `PRAGMA`, `VACUUM`, + `GLOB`, `REGEXP` and many more, and applications read this to decide which + identifiers need quoting. + - `{fn CURRENT_DATE()}`, `{fn CURRENT_TIME()}` and `{fn CURRENT_TIMESTAMP()}` now execute. `SQL_TIMEDATE_FUNCTIONS` advertised all three, but nothing translated them: SQLite spells them as bare keywords, `SELECT CURRENT_DATE();` diff --git a/src/backend.rs b/src/backend.rs index ef95fc5..c1ab9cf 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -6,8 +6,8 @@ use stackable_odbc_core::{ errors::OdbcError, types::{ ColumnDescriptor, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, InfoValue, - SQL_CN_ANY, SQL_GB_NO_RELATION, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TXN_SERIALIZABLE, - TypeInfoRow, + SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, SQL_NC_LOW, SQL_NNC_NON_NULL, + SQL_TXN_SERIALIZABLE, TypeInfoRow, }, }; @@ -476,6 +476,70 @@ impl Backend for SqliteBackend { 0 } + /// See `info::SQLITE_SUBQUERIES`. Notably excludes `SQL_SQ_QUANTIFIED`, + /// which core's default claimed while this driver's + /// `SQL_SQL92_PREDICATES` denied it. + fn subqueries() -> u32 { + info::SQLITE_SUBQUERIES + } + + /// SQLite accepts `SELECT a AS x`, and `AS` is optional. + fn column_alias() -> bool { + true + } + + /// `SQL_CB_NULL`: concatenating a NULL yields NULL — `'a' || NULL` is + /// NULL, not `'a'`. + fn concat_null_behavior() -> u16 { + SQL_CB_NULL + } + + /// See `info::SQLITE_UNION` — both `UNION` and `UNION ALL`. + fn union_support() -> u32 { + info::SQLITE_UNION + } + + /// See `info::SQLITE_CONVERT_FUNCTIONS` — `CAST` only. + fn convert_functions() -> u32 { + info::SQLITE_CONVERT_FUNCTIONS + } + + /// `false`: SQLite orders by expressions and by columns absent from the + /// select list, so `ORDER BY` is not restricted to selected columns. Same + /// permissiveness as [`SqliteBackend::group_by`]. + fn order_by_columns_in_select() -> bool { + false + } + + /// `true`: SQLite has no per-table permissions. Every table `SQLTables` + /// returns is one the connection can `SELECT` from, because opening the + /// database file is the only access check there is. + /// + /// This is the one value in this group that is a claim about the connected + /// principal rather than about SQL. It is safe here precisely because + /// SQLite has no principal. + fn accessible_tables() -> bool { + true + } + + /// `false`: the driver opens the database read-write. + /// + /// This describes the driver's own behaviour, not the file. A database on + /// read-only media, or one whose file permissions deny writes, still + /// reports `false` here and fails the write itself — which is what the + /// spec's "data source is set to READ ONLY mode" means. + fn data_source_read_only() -> bool { + false + } + + /// Backslash: SQLite's `LIKE ... ESCAPE` takes any character, and this + /// driver reports `SQL_LIKE_ESCAPE_CLAUSE = "Y"`. Backslash is the + /// conventional choice and the one `SQLTables`-style pattern arguments are + /// documented against. + fn search_pattern_escape() -> &'static str { + "\\" + } + // --- Delegations --- fn exec_direct(conn: &SqliteConnection, sql: &str) -> Result<SqliteStatement, SqliteError> { diff --git a/src/backend/info.rs b/src/backend/info.rs index bd83d4a..364dcfc 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -12,22 +12,25 @@ use stackable_odbc_core::types::{ SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, SQL_AT_ADD_COLUMN_COLLATION, SQL_AT_ADD_COLUMN_DEFAULT, SQL_AT_ADD_COLUMN_SINGLE, SQL_AT_ADD_CONSTRAINT, SQL_AT_ADD_TABLE_CONSTRAINT, SQL_AT_CONSTRAINT_NAME_DEFINITION, - SQL_CODE_DATE, SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_FN_NUM_ABS, SQL_FN_NUM_ROUND, - SQL_FN_NUM_SIGN, SQL_FN_STR_ASCII, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, SQL_FN_STR_LCASE, - SQL_FN_STR_LENGTH, SQL_FN_STR_LTRIM, SQL_FN_STR_OCTET_LENGTH, SQL_FN_STR_REPLACE, - SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, - SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, - SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, SQL_GD_ANY_COLUMN, - SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, SQL_NUMERIC_FUNCTIONS, - SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, - SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, SQL_SEARCHABLE, SQL_SP_BETWEEN, - SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, SQL_SP_ISNULL, SQL_SP_LIKE, - SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, - SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, - SQL_SRJO_INTERSECT_JOIN, SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, - SQL_SRJO_RIGHT_OUTER_JOIN, SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, - SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, - SqlDataType, TypeInfoRow, catalog_column_size, format_odbc_version, parse_dotted_version, + SQL_CODE_DATE, SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_FN_CVT_CAST, SQL_FN_NUM_ABS, + SQL_FN_NUM_ROUND, SQL_FN_NUM_SIGN, SQL_FN_STR_ASCII, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, + SQL_FN_STR_LCASE, SQL_FN_STR_LENGTH, SQL_FN_STR_LTRIM, SQL_FN_STR_OCTET_LENGTH, + SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, + SQL_FN_STR_UCASE, SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, + SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, + SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_KEYWORDS, + SQL_LIKE_ESCAPE_CLAUSE, SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, + SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, + SQL_SEARCHABLE, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, + SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, + SQL_SQ_IN, SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, + SQL_SQL92_VALUE_EXPRESSIONS, SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, + SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, + SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, + SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, + SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, SQL_U_UNION, + SQL_U_UNION_ALL, SqlDataType, TypeInfoRow, catalog_column_size, format_odbc_version, + parse_dotted_version, }; use super::SqliteBackend; @@ -741,6 +744,24 @@ pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = /// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> /// SQLite: <https://www.sqlite.org/lang_altertable.html> +/// `SQL_SUBQUERIES` (95) — the subquery forms SQLite accepts. +/// +/// `SQL_SQ_QUANTIFIED` is deliberately absent. It covers `< ALL` / `< ANY` / +/// `< SOME`, which SQLite does not parse — the same finding +/// `sql92_predicates_excludes_quantified_comparison_and_match` records for +/// `SQL_SP_QUANTIFIED_COMPARISON`. Core's default claimed it, so this driver +/// denied quantified comparison in one info type and asserted it in another. +/// Each remaining bit is exercised by `subqueries_are_each_live_probed`. +pub(crate) const SQLITE_SUBQUERIES: u32 = + SQL_SQ_COMPARISON | SQL_SQ_EXISTS | SQL_SQ_IN | SQL_SQ_CORRELATED_SUBQUERIES; + +/// `SQL_UNION` (96) — SQLite has both `UNION` and `UNION ALL`. +pub(crate) const SQLITE_UNION: u32 = SQL_U_UNION | SQL_U_UNION_ALL; + +/// `SQL_CONVERT_FUNCTIONS` (48) — SQLite's `CAST(x AS type)`. It has no +/// ODBC `CONVERT` scalar function, so only the `CAST` bit is claimed. +pub(crate) const SQLITE_CONVERT_FUNCTIONS: u32 = SQL_FN_CVT_CAST; + /// `SQL_OUTER_JOIN_CAPABILITIES` (115) — every outer-join form SQLite /// implements, and every relaxation of the `ON` clause the bitmap asks about. /// @@ -881,6 +902,313 @@ pub(crate) const SQLITE_TIMEDATE_FUNCTIONS: u32 = SQL_FN_TD_NOW | SQL_FN_TD_CURRENT_TIME | SQL_FN_TD_CURRENT_TIMESTAMP; +/// The ODBC reserved keywords, from Appendix C of the specification. +/// +/// `SQL_KEYWORDS` is defined as the data source's keywords *excluding* these: +/// "This list does not contain keywords specific to ODBC or keywords used by +/// both the data source and ODBC." Roughly the SQL-92 reserved list, which is +/// why one list suffices. +/// +/// Written in the order the specification page lists them rather than sorted, +/// so a reviewer can diff it against the source. Nothing here depends on the +/// order — [`sqlite_specific_keywords`] does a linear membership test, and +/// `odbc_reserved_keywords_are_unique` guards the one property that matters. +/// +/// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/reserved-keywords> +const ODBC_RESERVED_KEYWORDS: &[&str] = &[ + "ABSOLUTE", + "ACTION", + "ADA", + "ADD", + "ALL", + "ALLOCATE", + "ALTER", + "AND", + "ANY", + "ARE", + "AS", + "ASC", + "ASSERTION", + "AT", + "AUTHORIZATION", + "AVG", + "BEGIN", + "BETWEEN", + "BIT", + "BIT_LENGTH", + "BOTH", + "BY", + "CASCADE", + "CASCADED", + "CASE", + "CAST", + "CATALOG", + "CHAR", + "CHAR_LENGTH", + "CHARACTER", + "CHARACTER_LENGTH", + "CHECK", + "CLOSE", + "COALESCE", + "COLLATE", + "COLLATION", + "COLUMN", + "COMMIT", + "CONNECT", + "CONNECTION", + "CONSTRAINT", + "CONSTRAINTS", + "CONTINUE", + "CONVERT", + "CORRESPONDING", + "COUNT", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "CURSOR", + "DATE", + "DAY", + "DEALLOCATE", + "DEC", + "DECIMAL", + "DECLARE", + "DEFAULT", + "DEFERRABLE", + "DEFERRED", + "DELETE", + "DESC", + "DESCRIBE", + "DESCRIPTOR", + "DIAGNOSTICS", + "DISCONNECT", + "DISTINCT", + "DOMAIN", + "DOUBLE", + "DROP", + "ELSE", + "END", + "END-EXEC", + "ESCAPE", + "EXCEPT", + "EXCEPTION", + "EXEC", + "EXECUTE", + "EXISTS", + "EXTERNAL", + "EXTRACT", + "FALSE", + "FETCH", + "FIRST", + "FLOAT", + "FOR", + "FOREIGN", + "FORTRAN", + "FOUND", + "FROM", + "FULL", + "GET", + "GLOBAL", + "GO", + "GOTO", + "GRANT", + "GROUP", + "HAVING", + "HOUR", + "IDENTITY", + "IMMEDIATE", + "IN", + "INCLUDE", + "INDEX", + "INDICATOR", + "INITIALLY", + "INNER", + "INPUT", + "INSENSITIVE", + "INSERT", + "INT", + "INTEGER", + "INTERSECT", + "INTERVAL", + "INTO", + "IS", + "ISOLATION", + "JOIN", + "KEY", + "LANGUAGE", + "LAST", + "LEADING", + "LEFT", + "LEVEL", + "LIKE", + "LOCAL", + "LOWER", + "MATCH", + "MAX", + "MIN", + "MINUTE", + "MODULE", + "MONTH", + "NAMES", + "NATIONAL", + "NATURAL", + "NCHAR", + "NEXT", + "NO", + "NONE", + "NOT", + "NULL", + "NULLIF", + "NUMERIC", + "OCTET_LENGTH", + "OF", + "ON", + "ONLY", + "OPEN", + "OPTION", + "OR", + "ORDER", + "OUTER", + "OUTPUT", + "OVERLAPS", + "PAD", + "PARTIAL", + "PASCAL", + "POSITION", + "PRECISION", + "PREPARE", + "PRESERVE", + "PRIMARY", + "PRIOR", + "PRIVILEGES", + "PROCEDURE", + "PUBLIC", + "READ", + "REAL", + "REFERENCES", + "RELATIVE", + "RESTRICT", + "REVOKE", + "RIGHT", + "ROLLBACK", + "ROWS", + "SCHEMA", + "SCROLL", + "SECOND", + "SECTION", + "SELECT", + "SESSION", + "SESSION_USER", + "SET", + "SIZE", + "SMALLINT", + "SOME", + "SPACE", + "SQL", + "SQLCA", + "SQLCODE", + "SQLERROR", + "SQLSTATE", + "SQLWARNING", + "SUBSTRING", + "SUM", + "SYSTEM_USER", + "TABLE", + "TEMPORARY", + "THEN", + "TIME", + "TIMESTAMP", + "TIMEZONE_HOUR", + "TIMEZONE_MINUTE", + "TO", + "TRAILING", + "TRANSACTION", + "TRANSLATE", + "TRANSLATION", + "TRIM", + "TRUE", + "UNION", + "UNIQUE", + "UNKNOWN", + "UPDATE", + "UPPER", + "USAGE", + "USER", + "USING", + "VALUE", + "VALUES", + "VARCHAR", + "VARYING", + "VIEW", + "WHEN", + "WHENEVER", + "WHERE", + "WITH", + "WORK", + "WRITE", + "YEAR", + "ZONE", +]; + +/// `SQL_KEYWORDS` (89): SQLite's own keywords, minus those ODBC already +/// reserves, as a comma-separated list. +/// +/// The list is read out of the linked SQLite library through +/// `sqlite3_keyword_count` / `sqlite3_keyword_name` rather than transcribed +/// from <https://www.sqlite.org/lang_keywords.html>. A hand-copied list would +/// describe whichever SQLite the author was reading about; this describes the +/// one the driver is actually linked against, and needs no maintenance when +/// that changes. It is the same reason the `ALTER TABLE` and outer-join +/// bitmaps are live-probed. +/// +/// `stackable-odbc-core` answers this info type with an empty string, which is +/// a valid empty list and says SQLite has no keywords of its own. It has +/// plenty — `AUTOINCREMENT`, `PRAGMA`, `VACUUM`, `GLOB`, `REGEXP` — and +/// applications read this to decide which identifiers need quoting, so an +/// empty list can leave a generated identifier unquoted where it collides. +/// +/// Computed once: the underlying table is fixed at link time. +fn sqlite_specific_keywords() -> &'static str { + static KEYWORDS: std::sync::OnceLock<String> = std::sync::OnceLock::new(); + KEYWORDS.get_or_init(|| { + let count = unsafe { rusqlite::ffi::sqlite3_keyword_count() }; + let mut names: Vec<&'static str> = Vec::with_capacity(count.max(0) as usize); + + for i in 0..count { + let mut ptr: *const std::ffi::c_char = std::ptr::null(); + let mut len: std::ffi::c_int = 0; + // SAFETY: `i` is in `0..sqlite3_keyword_count()`, the range the API + // defines. On success it writes a pointer into SQLite's own static + // keyword table, valid for the life of the process, and its length; + // neither is owned by the caller, so the `'static` borrow is sound. + let rc = unsafe { rusqlite::ffi::sqlite3_keyword_name(i, &mut ptr, &mut len) }; + if rc != rusqlite::ffi::SQLITE_OK || ptr.is_null() || len <= 0 { + continue; + } + // SAFETY: as above — `ptr`/`len` describe a live, static, ASCII + // keyword that SQLite never mutates or frees. + let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }; + let Ok(name) = std::str::from_utf8(bytes) else { + continue; + }; + if !ODBC_RESERVED_KEYWORDS + .iter() + .any(|r| r.eq_ignore_ascii_case(name)) + { + names.push(name); + } + } + + // SQLite reports them in its own table order; sort so the value is + // stable for anything that diffs or caches it. + names.sort_unstable(); + names.join(",") + }) +} + pub(super) fn get_info_raw( _conn: &SqliteConnection, info_type: u16, @@ -928,6 +1256,11 @@ pub(super) fn get_info_raw( // since 3.39.0; this build is 3.53.2). SQL_LIKE_ESCAPE_CLAUSE => Some(Ok(InfoValue::String("Y".into()))), SQL_OUTER_JOINS => Some(Ok(InfoValue::String("Y".into()))), + // SQLite's own keywords, read from the linked library. Core answers + // this with an empty string, which claims SQLite has none of its own. + SQL_KEYWORDS => Some(Ok(InfoValue::String( + sqlite_specific_keywords().to_string(), + ))), _ => common_get_info_raw::<SqliteBackend>(info_type).map(Ok), } } @@ -1062,9 +1395,9 @@ mod tests { SQL_AT_DROP_COLUMN_CASCADE, SQL_AT_DROP_COLUMN_DEFAULT, SQL_AT_DROP_COLUMN_RESTRICT, SQL_AT_DROP_TABLE_CONSTRAINT_CASCADE, SQL_AT_DROP_TABLE_CONSTRAINT_RESTRICT, SQL_AT_SET_COLUMN_DEFAULT, SQL_CA1_NEXT, SQL_CB_PRESERVE, SQL_CN_ANY, - SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, - SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, - SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, + SQL_DRIVER_ODBC_VER_STRING, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, SQL_FN_NUM_FLOOR, + SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, SQL_FN_NUM_SQRT, + SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, SQL_FN_STR_CHARACTER_LENGTH, SQL_FN_STR_DIFFERENCE, SQL_FN_STR_INSERT, SQL_FN_STR_LEFT, SQL_FN_STR_LOCATE, SQL_FN_STR_LOCATE_2, SQL_FN_STR_POSITION, SQL_FN_STR_REPEAT, SQL_FN_STR_RIGHT, SQL_FN_STR_SPACE, SQL_FN_TD_DAYNAME, SQL_FN_TD_DAYOFMONTH, @@ -1077,7 +1410,7 @@ mod tests { SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, - SQL_TXN_SERIALIZABLE, SQL_U_UNION, SQL_U_UNION_ALL, + SQL_TXN_SERIALIZABLE, }; enum Expected { @@ -1156,11 +1489,14 @@ mod tests { // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT -- see // the matching comment in stackable-odbc-core's default_get_info. (InfoType::CursorSensitivity, Expected::U32(SQL_INSENSITIVE as u32)), - (InfoType::Subqueries, Expected::U32(SQL_SQ_COMPARISON | SQL_SQ_EXISTS | SQL_SQ_IN | SQL_SQ_QUANTIFIED | SQL_SQ_CORRELATED_SUBQUERIES)), - (InfoType::UnionStatement, Expected::U32(SQL_U_UNION | SQL_U_UNION_ALL)), + // SQL_SQ_QUANTIFIED dropped: `< ALL` / `< ANY` / `< SOME` do not + // parse, which SQL_SQL92_PREDICATES already recorded. Core's default + // claimed it, so the two info types disagreed. + (InfoType::Subqueries, Expected::U32(SQLITE_SUBQUERIES)), + (InfoType::UnionStatement, Expected::U32(SQLITE_UNION)), (InfoType::DefaultTxnIsolation, Expected::U32(SQL_TXN_SERIALIZABLE)), (InfoType::ScrollOptions, Expected::U32(SQL_SO_FORWARD_ONLY)), - (InfoType::ConvertFunctions, Expected::U32(SQL_FN_CVT_CAST)), + (InfoType::ConvertFunctions, Expected::U32(SQLITE_CONVERT_FUNCTIONS)), // SERIALIZABLE only: READ COMMITTED and REPEATABLE READ do not exist // in SQLite, and READ UNCOMMITTED needs shared-cache mode, which this // driver never enables. @@ -1342,6 +1678,125 @@ mod tests { assert_eq!(orphans, 0, "ON DELETE CASCADE did not cascade"); } + /// Every `SQL_SUBQUERIES` bit this driver claims, proved by preparing the + /// subquery form it describes — and the one it does not claim, proved by + /// the bundled library rejecting it. + /// + /// `SQL_SQ_QUANTIFIED` is the point. Core's default claimed it while this + /// driver's `SQL_SQL92_PREDICATES` denied `SQL_SP_QUANTIFIED_COMPARISON`, + /// so the same capability was advertised and denied by two info types. A + /// BI tool reading `SQL_SUBQUERIES` would push down `< ALL` and get a + /// syntax error. + #[test] + fn subqueries_are_each_live_probed() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch("CREATE TABLE t (a INTEGER, b INTEGER); CREATE TABLE u (b INTEGER);") + .unwrap(); + + for (bit, sql) in [ + ( + SQL_SQ_COMPARISON, + "SELECT * FROM t WHERE a < (SELECT max(b) FROM u)", + ), + ( + SQL_SQ_EXISTS, + "SELECT * FROM t WHERE EXISTS (SELECT 1 FROM u)", + ), + (SQL_SQ_IN, "SELECT * FROM t WHERE a IN (SELECT b FROM u)"), + ( + SQL_SQ_CORRELATED_SUBQUERIES, + "SELECT * FROM t WHERE a IN (SELECT b FROM u WHERE u.b = t.b)", + ), + ] { + assert!( + SQLITE_SUBQUERIES & bit == bit, + "probe listed for unclaimed bit {bit:#x}" + ); + conn.prepare(sql).unwrap_or_else(|e| { + panic!("SQL_SQ bit {bit:#x} claimed but SQLite rejected it: {e}\n {sql}") + }); + } + + assert_eq!( + SQLITE_SUBQUERIES & SQL_SQ_QUANTIFIED, + 0, + "SQL_SQ_QUANTIFIED must not be claimed while SQL_SQL92_PREDICATES \ + denies SQL_SP_QUANTIFIED_COMPARISON" + ); + for sql in [ + "SELECT * FROM t WHERE a < ALL (SELECT b FROM u)", + "SELECT * FROM t WHERE a < ANY (SELECT b FROM u)", + "SELECT * FROM t WHERE a < SOME (SELECT b FROM u)", + ] { + assert!( + conn.prepare(sql).is_err(), + "SQLite now parses a quantified comparison, so SQL_SQ_QUANTIFIED \ + and SQL_SP_QUANTIFIED_COMPARISON should both be claimed\n {sql}" + ); + } + } + + /// `SQL_KEYWORDS` lists SQLite's own keywords and excludes the ones ODBC + /// already reserves. + /// + /// The list is read out of the linked library, so this asserts properties + /// rather than a fixed string: a `rusqlite` bump may legitimately add a + /// keyword, and pinning the exact value would turn that into a failure. + #[test] + fn sql_keywords_lists_sqlite_specific_keywords_only() { + let keywords = sqlite_specific_keywords(); + let listed: Vec<&str> = keywords.split(',').filter(|s| !s.is_empty()).collect(); + + assert!( + !listed.is_empty(), + "SQLite has keywords of its own; an empty list is the claim core's \ + default made and this arm exists to correct" + ); + + // Present: unmistakably SQLite, and absent from the ODBC list. + for expected in ["AUTOINCREMENT", "PRAGMA", "VACUUM", "GLOB", "REGEXP"] { + assert!( + listed.contains(&expected), + "{expected} is a SQLite keyword but is missing from SQL_KEYWORDS" + ); + } + + // Absent: reserved by ODBC, so excluded by the spec's definition. + for reserved in ["SELECT", "FROM", "WHERE", "PRIMARY", "TABLE"] { + assert!( + !listed.contains(&reserved), + "{reserved} is an ODBC reserved keyword and must not appear in \ + SQL_KEYWORDS" + ); + } + + let mut sorted = listed.clone(); + sorted.sort_unstable(); + assert_eq!(listed, sorted, "SQL_KEYWORDS should be sorted"); + assert!( + !keywords.contains(", "), + "the spec asks for a comma-separated list, not comma-space" + ); + } + + /// The ODBC reserved list is transcribed from the specification page, so + /// the one mistake worth guarding is a duplicated entry from a bad merge. + #[test] + fn odbc_reserved_keywords_are_unique() { + let mut seen = std::collections::HashSet::new(); + for k in ODBC_RESERVED_KEYWORDS { + assert!( + seen.insert(*k), + "{k} appears twice in ODBC_RESERVED_KEYWORDS" + ); + assert_eq!( + *k, + k.to_ascii_uppercase(), + "{k} should be uppercase, matching the spec page" + ); + } + } + /// SQLite's `GROUP BY` is unrelated to the select list, which is what /// `SQL_GB_NO_RELATION` means and what rules out the SQL-92 entry level. /// From f4dd2ad3c490648e0f5feedc0f5a4616dae3f0b1 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 21:04:04 +0200 Subject: [PATCH 20/50] docs: record who owns SQLGetInfo values now, and how to declare one The ownership table said core supplies the generic SQLGetInfo defaults. That stopped being true across four core releases: every value describing the data source is now a required Backend method, and leaving one unstated is a compile error rather than a silent inherited claim. An agent reading the old table would look for defaults that no longer exist. Adds a Declaring capabilities section carrying the three rules this work produced. Probe the bundled library rather than the documentation or the system sqlite3 -- they are different versions, and writing the ALTER TABLE bitmap from the CLI's behaviour got two bits wrong. Probe the bits not claimed as well, which is what caught them. And check that a new value agrees with the other info types describing the same capability, with a table of the five places this crate previously contradicted itself. Also records that connect issues PRAGMA foreign_keys = ON, and why the SQL_INTEGRITY claim depends on it rather than on a dependency's build flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9cdc0d4..0d368b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ the 73 C ABI entry points — lives in | [Relationship to core](#relationship-to-stackable-odbc-core) | Deciding where a change belongs | | [Conventions](#conventions) | Any code change | | [Backend error mapping](#backend-error-mapping) | Touching an error path | +| [Declaring capabilities](#declaring-capabilities) | Adding or changing any `SQLGetInfo` value | | [Transactions](#transactions) | Touching `SQLEndTran`, autocommit or cursor behaviour | | [Architecture](#architecture-of-this-crate) | Understanding the module layout | | [Connection string keys](#connection-string-keys) | Adding or changing a parameter | @@ -52,7 +53,8 @@ crates.io; releases are GitHub Release archives built by | Handle allocation, tag validation, `panic_safe` | core | | UTF-16 marshalling, diagnostics, `SQLGetDiagRec` | core | | The 73 exported C ABI entry points (`forward_ffi!`) | core | -| Generic `SQLGetInfo` defaults, cursor-state tracking | core | +| `SQLGetInfo` marshalling and shape checking, cursor-state tracking | core | +| Every `SQLGetInfo` value that describes SQLite | this crate — see [Declaring capabilities](#declaring-capabilities) | | `Backend` / `StatementBackend` trait definitions | core | | Opening the database, executing, fetching | this crate | | SQLite storage class → SQL type mapping, value conversion | this crate | @@ -150,8 +152,60 @@ For this driver `connect` is where real I/O happens: `rusqlite::Connection::open` touches the filesystem, so a missing or unreadable database file is `08001`. Failures after that point are `08S01`. +### Declaring capabilities + +`Backend` has around two dozen **required** methods that state what SQLite can +do — `alter_table_support`, `outer_join_capabilities`, `subqueries`, +`sql_conformance`, `supports_catalogs`, `txn_isolation_options` and the rest. +They are required, with no default, deliberately: a defaulted capability is a +claim no backend ever made, and every one of them was a bug here before core +made it a compile error. + +Three rules, all learned the hard way: + +**Probe the bundled library, never the documentation or the system CLI.** +`rusqlite` links its own SQLite (3.53.2 via the `bundled` feature); the +`sqlite3` binary on a developer's machine is a different version. Writing the +`ALTER TABLE` bitmap from the system CLI's behaviour got `ADD CONSTRAINT` and +`DROP CONSTRAINT` wrong, because 3.51.3 rejects both and 3.53.2 accepts them. +`alter_table_capabilities_are_each_live_probed`, +`outer_join_capabilities_are_each_live_probed` and +`subqueries_are_each_live_probed` all execute the syntax they describe. + +**Probe the bits you do not claim, too.** A test that only checks what a bitmap +claims can overclaim forever, and a bitmap that only grows when someone notices +can understate forever. The negative half of the `ALTER TABLE` probe is what +caught the two bits above. `SQL_KEYWORDS` goes further and reads the list out +of the library through `sqlite3_keyword_count` / `sqlite3_keyword_name`, so it +needs no maintenance at all. + +**Values must agree with each other.** Most defects found in this crate were +one capability stated twice, in opposite directions: + +| Said one thing | Said the opposite | +|---|---| +| `SQL_CATALOG_NAME = "N"` | `SQL_CATALOG_TERM = "catalog"` | +| `SQL_OUTER_JOINS = "Y"` | `SQL_OUTER_JOIN_CAPABILITIES = 0` | +| `SQL_SQL_CONFORMANCE = SQL_SC_SQL92_ENTRY` | `SQL_GROUP_BY = SQL_GB_NO_RELATION` | +| `SQL_SQL92_PREDICATES` without `SQL_SP_QUANTIFIED_COMPARISON` | `SQL_SUBQUERIES` with `SQL_SQ_QUANTIFIED` | +| `SQL_TXN_ISOLATION_OPTION` with four levels | nothing applying the level an application sets | + +When adding or changing a capability, look for the other info type that talks +about the same thing, and assert the relationship — +`catalog_and_schema_info_types_agree_with_each_other` and +`transaction_isolation_offers_only_the_level_sqlite_implements` are that check, +and they assert the spec's rule rather than today's values, so they keep +holding if the answer changes. + ### Transactions +`connect` issues `PRAGMA foreign_keys = ON`. SQLite leaves it off for backward +compatibility, and the bundled library only happens to compile with +`SQLITE_DEFAULT_FOREIGN_KEYS` — so without the pragma, `SQL_INTEGRITY = "Y"` +would depend on a dependency's build flags rather than on this driver. +`integrity_enhancement_facility_is_actually_enforced` checks it through +`connect`. + SQLite supports transactions and this driver reports `SQL_TC_DML` for `SQL_TXN_CAPABLE`, so manual-commit mode is honoured for real: `set_autocommit(false)` issues `BEGIN`, and `end_tran` issues `COMMIT` or From 5e1dadc5349e4a890732a505a964f0cac97d7c11 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 21:10:26 +0200 Subject: [PATCH 21/50] fix: report the SQL_MAX_* limits SQLite can actually be asked for Seven SQL_MAX_* values reported 0. The spec allows that for "no specified limit or the limit is unknown", and stackable-odbc-core answers it because it genuinely cannot know -- but SQLite enforces real limits, and an application reads these to decide whether to chunk a wide SELECT or a long IN list. 0 tells it there is nothing to chunk around. They are read from the connection through sqlite3_limit rather than hardcoded. That is not fastidiousness: sqlite3_limit both reads and writes, so a compile-time constant would be wrong for any connection that lowered one. The test proves the distinction by changing the limit and asserting the reported value follows -- comparing against SQLite's default would pass just as well against a hardcoded 2000. Enabling rusqlite's "limits" feature costs nothing; it is defined as an empty feature list and only unlocks the safe wrapper. SQL_MAX_TABLES_IN_SELECT keeps its 0. SQLite caps a join at 64 tables, but that is a compile-time constant with no sqlite3_limit behind it, and transcribing a documented constant is what produced the wrong ALTER TABLE bitmap earlier in this branch. Also zeroes SQL_MAX_CATALOG_NAME_LEN and SQL_MAX_SCHEMA_NAME_LEN, which inherited core's generic identifier length while this driver reports neither catalogs nor schemas -- a maximum length for a name that cannot exist. Both follow the support hooks rather than being pinned, so they stay correct if either flips. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 17 ++++ Cargo.toml | 4 +- src/backend/info.rs | 191 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 208 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe239c..b040650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `SQL_MAX_COLUMNS_IN_SELECT`, `_IN_TABLE`, `_IN_GROUP_BY`, `_IN_ORDER_BY`, + `_IN_INDEX`, `SQL_MAX_STATEMENT_LEN` and `SQL_MAX_ROW_SIZE` now report the + connection's actual limits instead of `0`. The spec allows `0` for "no + specified limit or the limit is unknown", and `stackable-odbc-core` answers + that because it cannot know — but SQLite enforces real limits, and a tool + deciding whether to chunk a wide `SELECT` or a long `IN` list reads exactly + these. They are read per connection through `sqlite3_limit` rather than + hardcoded, because `sqlite3_limit` also *sets* them, so any constant would be + wrong for a connection that changed one. `SQL_MAX_TABLES_IN_SELECT` stays `0`: + SQLite's 64-table join cap has no `sqlite3_limit` to read it from, and + transcribing the constant is what has gone stale twice in this crate. + +- `SQL_MAX_CATALOG_NAME_LEN` and `SQL_MAX_SCHEMA_NAME_LEN` now report `0` + instead of the generic identifier length. This driver supports neither + catalogs nor schemas, so there is no name for these to bound; they were + stating a maximum length for something the same driver says does not exist. + - `SQL_SUBQUERIES` no longer claims `SQL_SQ_QUANTIFIED`. `< ALL`, `< ANY` and `< SOME` are all syntax errors in SQLite, which this driver already recorded by excluding `SQL_SP_QUANTIFIED_COMPARISON` from `SQL_SQL92_PREDICATES` — so diff --git a/Cargo.toml b/Cargo.toml index a513695..958c632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,9 @@ categories = ["database", "external-ffi-bindings", "api-bindings"] crate-type = ["cdylib", "rlib"] [dependencies] -rusqlite = { version = "0.40", features = ["bundled", "column_decltype"] } +# "limits" is dependency-free; it exposes sqlite3_limit, which SQL_MAX_* is +# read from rather than hardcoded. +rusqlite = { version = "0.40", features = ["bundled", "column_decltype", "limits"] } snafu = "0.9" # TODO: switch to a crates.io version dep once stackable-odbc-core is published. stackable-odbc-core = { path = "../stackable-odbc-core" } diff --git a/src/backend/info.rs b/src/backend/info.rs index 364dcfc..88acaae 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -36,6 +36,7 @@ use stackable_odbc_core::types::{ use super::SqliteBackend; use super::SqliteConnection; use super::SqliteError; +use super::map_sqlite_error; use crate::type_conversion::{ BLOB_DEFAULT_COLUMN_SIZE, DECIMAL_DEFAULT_COLUMN_SIZE, MAX_FRACTIONAL_SECONDS_PRECISION, VARCHAR_DEFAULT_COLUMN_SIZE, @@ -600,6 +601,18 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { })); } InfoType::IdentifierCase => return Ok(InfoValue::U16(SQL_IC_MIXED)), + // 0, not an identifier length: this driver reports no catalogs and no + // schemas, so there is no name whose maximum length these could + // describe. Core defaults them to its generic identifier length, which + // states a bound on something it has just said does not exist. The + // spec defines 0 as "no maximum length or the length is unknown", + // which is the closest available reading of "not applicable". + InfoType::MaxCatalogNameLen if !SqliteBackend::supports_catalogs() => { + return Ok(InfoValue::U16(0)); + } + InfoType::MaxSchemaNameLen if !SqliteBackend::supports_schemas() => { + return Ok(InfoValue::U16(0)); + } // "Y": SQLite implements the whole Integrity Enhancement Facility -- // PRIMARY KEY, UNIQUE, NOT NULL, CHECK, DEFAULT and FOREIGN KEY with // referential actions -- and this build enforces all of it. Core @@ -674,12 +687,81 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { } pub(super) fn get_info( - _conn: &SqliteConnection, + conn: &SqliteConnection, info_type: InfoType, ) -> Result<InfoValue, SqliteError> { + if let Some(value) = connection_limit(conn, info_type)? { + return Ok(value); + } sqlite_get_info(info_type) } +/// The `SQL_MAX_*` values SQLite can be asked for directly, via +/// `sqlite3_limit` (`rusqlite::Connection::limit`, a safe wrapper). +/// +/// The spec allows `0` for "no specified limit or the limit is unknown", and +/// core answers `0` for exactly that reason — it has no way to know. This +/// driver does: these are real, enforced limits, and an application reads them +/// to decide whether to chunk a wide `SELECT` or a long `IN` list. `0` tells it +/// there is nothing to chunk around. +/// +/// They are read per connection rather than hardcoded because they are +/// per-connection settable: `sqlite3_limit` both reads and writes, so a +/// compile-time constant would be wrong for any connection that changed one. +/// +/// Returns `None` for every other info type, leaving `sqlite_get_info` to +/// answer. `get_info_pre_connect` has no connection and so keeps reporting +/// `0` — with no connection the limit genuinely is unknown, which is what `0` +/// means. +fn connection_limit( + conn: &SqliteConnection, + info_type: InfoType, +) -> Result<Option<InfoValue>, SqliteError> { + use rusqlite::limits::Limit; + + let limit = match info_type { + // All five are bounded by the per-connection column limit: SQLite + // applies SQLITE_LIMIT_COLUMN to a table definition, a result set, and + // the terms of a GROUP BY / ORDER BY / index alike. + InfoType::MaxColumnsInSelect + | InfoType::MaxColumnsInTable + | InfoType::MaxColumnsInGroupBy + | InfoType::MaxColumnsInOrderBy + | InfoType::MaxColumnsInIndex => Limit::SQLITE_LIMIT_COLUMN, + InfoType::MaxStatementLen => Limit::SQLITE_LIMIT_SQL_LENGTH, + // SQL_MAX_ROW_SIZE is the largest row the data source will accept. + // SQLITE_LIMIT_LENGTH bounds any single string or blob, which is the + // binding constraint on a row: SQLite imposes no separate row width. + InfoType::MaxRowSize => Limit::SQLITE_LIMIT_LENGTH, + // Deliberately absent: SQL_MAX_TABLES_IN_SELECT. SQLite caps a join at + // 64 tables, but that is a compile-time constant with no sqlite3_limit + // to read it from, and hardcoding 64 here would be the kind of + // transcribed-from-documentation value that has gone stale twice in + // this crate. It keeps core's 0, "unknown". + _ => return Ok(None), + }; + + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), + })?; + let raw = db.limit(limit).map_err(map_sqlite_error)?; + + // sqlite3_limit returns the current value, always non-negative in practice; + // a negative would mean "query, do not set" leaked through, so treat it as + // unknown rather than wrapping it into a huge unsigned number. + if raw < 0 { + return Ok(None); + } + + Ok(Some(match info_type { + InfoType::MaxStatementLen | InfoType::MaxRowSize => InfoValue::U32(raw as u32), + // The column limits are SQLUSMALLINT. SQLite caps SQLITE_LIMIT_COLUMN + // at 32767 so this cannot truncate, but clamp rather than cast so a + // future cap increase understates instead of wrapping to a small number. + _ => InfoValue::U16(u16::try_from(raw).unwrap_or(u16::MAX)), + })) +} + pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, OdbcError> { sqlite_get_info(info_type).map_err(Into::into) } @@ -1464,8 +1546,8 @@ mod tests { (InfoType::IdentifierCase, Expected::U16(SQL_IC_MIXED)), (InfoType::MaxColumnNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), (InfoType::MaxCursorNameLen, Expected::U16(SQL_MAX_CURSOR_NAME_LEN)), - (InfoType::MaxSchemaNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), - (InfoType::MaxCatalogNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::MaxSchemaNameLen, Expected::U16(0)), + (InfoType::MaxCatalogNameLen, Expected::U16(0)), (InfoType::MaxTableNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), (InfoType::NullCollation, Expected::U16(SQL_NC_LOW)), // These three were never in this snapshot: core invented them until it @@ -1678,6 +1760,109 @@ mod tests { assert_eq!(orphans, 0, "ON DELETE CASCADE did not cascade"); } + /// The `SQL_MAX_*` values that SQLite can be asked for come from the + /// connection, not from a constant. + /// + /// Asserted by *changing* the limit and watching the reported value follow. + /// Checking it merely equals SQLite's default would pass just as well + /// against a hardcoded 2000, which is the thing this is meant to rule out. + #[test] + fn max_limits_are_read_from_the_connection() { + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let sqlite_conn = SqliteBackend::connect(&params).expect("connect"); + + let column_limited = [ + InfoType::MaxColumnsInSelect, + InfoType::MaxColumnsInTable, + InfoType::MaxColumnsInGroupBy, + InfoType::MaxColumnsInOrderBy, + InfoType::MaxColumnsInIndex, + ]; + + // Default: whatever the bundled library carries, but never core's 0. + for info_type in column_limited { + match get_info(&sqlite_conn, info_type) { + Ok(InfoValue::U16(v)) => assert!( + v > 0, + "{info_type:?} reported 0 -- the connection limit was not read" + ), + other => panic!("{info_type:?} unexpected: {other:?}"), + } + } + + // Lower SQLITE_LIMIT_COLUMN and every one of them must move with it. + { + let db = sqlite_conn.conn.lock().expect("lock"); + db.set_limit(rusqlite::limits::Limit::SQLITE_LIMIT_COLUMN, 42) + .expect("set limit"); + } + for info_type in column_limited { + assert_eq!( + get_info(&sqlite_conn, info_type).expect("info"), + InfoValue::U16(42), + "{info_type:?} did not follow SQLITE_LIMIT_COLUMN" + ); + } + + // The two SQLUINTEGER limits, same argument. + for (info_type, limit) in [ + ( + InfoType::MaxStatementLen, + rusqlite::limits::Limit::SQLITE_LIMIT_SQL_LENGTH, + ), + ( + InfoType::MaxRowSize, + rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH, + ), + ] { + { + let db = sqlite_conn.conn.lock().expect("lock"); + db.set_limit(limit, 4096).expect("set limit"); + } + assert_eq!( + get_info(&sqlite_conn, info_type).expect("info"), + InfoValue::U32(4096), + "{info_type:?} did not follow its sqlite3_limit" + ); + } + + // Not claimed: SQLite's 64-table join cap has no sqlite3_limit, so this + // stays core's 0 rather than a transcribed constant. + assert_eq!( + get_info(&sqlite_conn, InfoType::MaxTablesInSelect).expect("info"), + InfoValue::U16(0), + "SQL_MAX_TABLES_IN_SELECT has no limit to read and should stay 0" + ); + } + + /// A maximum name length for a namespace this driver says does not exist + /// is a bound on nothing. Kept in step with the two hooks rather than + /// pinned to 0, so it stays right if either ever flips. + #[test] + fn catalog_and_schema_name_lengths_follow_their_support_hooks() { + let max_catalog = sqlite_get_info(InfoType::MaxCatalogNameLen).expect("info"); + let max_schema = sqlite_get_info(InfoType::MaxSchemaNameLen).expect("info"); + + if SqliteBackend::supports_catalogs() { + assert_ne!(max_catalog, InfoValue::U16(0)); + } else { + assert_eq!( + max_catalog, + InfoValue::U16(0), + "SQL_MAX_CATALOG_NAME_LEN bounds a name that cannot exist" + ); + } + if SqliteBackend::supports_schemas() { + assert_ne!(max_schema, InfoValue::U16(0)); + } else { + assert_eq!( + max_schema, + InfoValue::U16(0), + "SQL_MAX_SCHEMA_NAME_LEN bounds a name that cannot exist" + ); + } + } + /// Every `SQL_SUBQUERIES` bit this driver claims, proved by preparing the /// subquery form it describes — and the one it does not claim, proved by /// the bundled library rejecting it. From 172c6b5d591b8f79373e3de610c2a4d0ed38d83f Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sun, 26 Jul 2026 21:31:44 +0200 Subject: [PATCH 22/50] refactor!: report raw keywords and let core apply the ODBC subtraction stackable-odbc-core added a required Backend::keywords and took ownership of ODBC_RESERVED_KEYWORDS and the filtering, so the spec's "excluding ODBC's own" rule is applied once across drivers instead of per backend. This driver now returns the raw list and deletes its own copy of the Appendix C table along with the subtract-sort-join it was doing. That table was a transcription of a specification page duplicated in every driver that needed it -- exactly the kind of shared fact core exists to hold. The list is still read out of the linked library rather than transcribed, and is still cached behind a OnceLock: core recomputes SQL_KEYWORDS on every call, because it cannot cache a value generic over the backend, and walking SQLite's keyword table each time would be wasteful. The test now asserts both halves, since each can fail alone. The raw list must still contain SELECT -- filtering is core's job, and doing it here too would reintroduce the duplication -- while the value reaching an application through get_info_raw must not, and must be shorter than the raw list, which is what proves the subtraction ran at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 8 +- src/backend.rs | 10 + src/backend/info.rs | 451 +++++++++----------------------------------- 3 files changed, 105 insertions(+), 364 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b040650..3021252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,11 +130,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `SQL_KEYWORDS` now lists SQLite's own keywords instead of an empty string. The list is read out of the linked library through `sqlite3_keyword_count` / - `sqlite3_keyword_name` and filtered against the ODBC reserved list, which the - specification defines this value as excluding. An empty list claimed SQLite + `sqlite3_keyword_name` rather than transcribed from SQLite's documentation, + so it describes the library the driver links. An empty list claimed SQLite has no keywords of its own — it has `AUTOINCREMENT`, `PRAGMA`, `VACUUM`, `GLOB`, `REGEXP` and many more, and applications read this to decide which - identifiers need quoting. + identifiers need quoting. The driver reports the raw list through + `Backend::keywords`; `stackable-odbc-core` subtracts the ODBC reserved words + the specification defines this value as excluding. - `{fn CURRENT_DATE()}`, `{fn CURRENT_TIME()}` and `{fn CURRENT_TIMESTAMP()}` now execute. `SQL_TIMEDATE_FUNCTIONS` advertised all three, but nothing diff --git a/src/backend.rs b/src/backend.rs index c1ab9cf..975eef7 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -532,6 +532,16 @@ impl Backend for SqliteBackend { false } + /// SQLite's reserved words, read out of the linked library rather than + /// transcribed. See `info::sqlite_keywords`. + /// + /// This is the raw list; core subtracts `ODBC_RESERVED_KEYWORDS` and joins + /// it into `SQL_KEYWORDS`, so the "excluding ODBC's own" rule is applied + /// once for every driver instead of per backend. + fn keywords() -> &'static [&'static str] { + info::sqlite_keywords() + } + /// Backslash: SQLite's `LIKE ... ESCAPE` takes any character, and this /// driver reports `SQL_LIKE_ESCAPE_CLAUSE = "Y"`. Backslash is the /// conventional choice and the one `SQLTables`-style pattern arguments are diff --git a/src/backend/info.rs b/src/backend/info.rs index 88acaae..0d608bd 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -18,19 +18,18 @@ use stackable_odbc_core::types::{ SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, - SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_KEYWORDS, - SQL_LIKE_ESCAPE_CLAUSE, SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, - SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, - SQL_SEARCHABLE, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, - SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, - SQL_SQ_IN, SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, - SQL_SQL92_VALUE_EXPRESSIONS, SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, - SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, - SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, - SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, - SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, SQL_U_UNION, - SQL_U_UNION_ALL, SqlDataType, TypeInfoRow, catalog_column_size, format_odbc_version, - parse_dotted_version, + SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, + SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, + SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, SQL_SEARCHABLE, + SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, SQL_SP_ISNULL, + SQL_SP_LIKE, SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, + SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, + SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, + SQL_SRJO_INTERSECT_JOIN, SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, + SQL_SRJO_RIGHT_OUTER_JOIN, SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, + SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, + SQL_U_UNION, SQL_U_UNION_ALL, SqlDataType, TypeInfoRow, catalog_column_size, + format_odbc_version, parse_dotted_version, }; use super::SqliteBackend; @@ -984,311 +983,53 @@ pub(crate) const SQLITE_TIMEDATE_FUNCTIONS: u32 = SQL_FN_TD_NOW | SQL_FN_TD_CURRENT_TIME | SQL_FN_TD_CURRENT_TIMESTAMP; -/// The ODBC reserved keywords, from Appendix C of the specification. +/// SQLite's reserved words, read out of the linked library. /// -/// `SQL_KEYWORDS` is defined as the data source's keywords *excluding* these: -/// "This list does not contain keywords specific to ODBC or keywords used by -/// both the data source and ODBC." Roughly the SQL-92 reserved list, which is -/// why one list suffices. +/// `Backend::keywords` returns the **raw** list: core subtracts +/// `ODBC_RESERVED_KEYWORDS`, sorts and joins it into `SQL_KEYWORDS` (89), so +/// the spec's "excluding ODBC's own" rule lives in one place across drivers +/// rather than being reimplemented per backend. /// -/// Written in the order the specification page lists them rather than sorted, -/// so a reviewer can diff it against the source. Nothing here depends on the -/// order — [`sqlite_specific_keywords`] does a linear membership test, and -/// `odbc_reserved_keywords_are_unique` guards the one property that matters. +/// The names come from `sqlite3_keyword_count` / `sqlite3_keyword_name` rather +/// than from <https://www.sqlite.org/lang_keywords.html>. A transcribed list +/// would describe whichever SQLite the author was reading about; this describes +/// the one the driver is linked against, and needs no maintenance when that +/// changes. Same reason the `ALTER TABLE` and outer-join bitmaps are probed. /// -/// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/reserved-keywords> -const ODBC_RESERVED_KEYWORDS: &[&str] = &[ - "ABSOLUTE", - "ACTION", - "ADA", - "ADD", - "ALL", - "ALLOCATE", - "ALTER", - "AND", - "ANY", - "ARE", - "AS", - "ASC", - "ASSERTION", - "AT", - "AUTHORIZATION", - "AVG", - "BEGIN", - "BETWEEN", - "BIT", - "BIT_LENGTH", - "BOTH", - "BY", - "CASCADE", - "CASCADED", - "CASE", - "CAST", - "CATALOG", - "CHAR", - "CHAR_LENGTH", - "CHARACTER", - "CHARACTER_LENGTH", - "CHECK", - "CLOSE", - "COALESCE", - "COLLATE", - "COLLATION", - "COLUMN", - "COMMIT", - "CONNECT", - "CONNECTION", - "CONSTRAINT", - "CONSTRAINTS", - "CONTINUE", - "CONVERT", - "CORRESPONDING", - "COUNT", - "CREATE", - "CROSS", - "CURRENT", - "CURRENT_DATE", - "CURRENT_TIME", - "CURRENT_TIMESTAMP", - "CURRENT_USER", - "CURSOR", - "DATE", - "DAY", - "DEALLOCATE", - "DEC", - "DECIMAL", - "DECLARE", - "DEFAULT", - "DEFERRABLE", - "DEFERRED", - "DELETE", - "DESC", - "DESCRIBE", - "DESCRIPTOR", - "DIAGNOSTICS", - "DISCONNECT", - "DISTINCT", - "DOMAIN", - "DOUBLE", - "DROP", - "ELSE", - "END", - "END-EXEC", - "ESCAPE", - "EXCEPT", - "EXCEPTION", - "EXEC", - "EXECUTE", - "EXISTS", - "EXTERNAL", - "EXTRACT", - "FALSE", - "FETCH", - "FIRST", - "FLOAT", - "FOR", - "FOREIGN", - "FORTRAN", - "FOUND", - "FROM", - "FULL", - "GET", - "GLOBAL", - "GO", - "GOTO", - "GRANT", - "GROUP", - "HAVING", - "HOUR", - "IDENTITY", - "IMMEDIATE", - "IN", - "INCLUDE", - "INDEX", - "INDICATOR", - "INITIALLY", - "INNER", - "INPUT", - "INSENSITIVE", - "INSERT", - "INT", - "INTEGER", - "INTERSECT", - "INTERVAL", - "INTO", - "IS", - "ISOLATION", - "JOIN", - "KEY", - "LANGUAGE", - "LAST", - "LEADING", - "LEFT", - "LEVEL", - "LIKE", - "LOCAL", - "LOWER", - "MATCH", - "MAX", - "MIN", - "MINUTE", - "MODULE", - "MONTH", - "NAMES", - "NATIONAL", - "NATURAL", - "NCHAR", - "NEXT", - "NO", - "NONE", - "NOT", - "NULL", - "NULLIF", - "NUMERIC", - "OCTET_LENGTH", - "OF", - "ON", - "ONLY", - "OPEN", - "OPTION", - "OR", - "ORDER", - "OUTER", - "OUTPUT", - "OVERLAPS", - "PAD", - "PARTIAL", - "PASCAL", - "POSITION", - "PRECISION", - "PREPARE", - "PRESERVE", - "PRIMARY", - "PRIOR", - "PRIVILEGES", - "PROCEDURE", - "PUBLIC", - "READ", - "REAL", - "REFERENCES", - "RELATIVE", - "RESTRICT", - "REVOKE", - "RIGHT", - "ROLLBACK", - "ROWS", - "SCHEMA", - "SCROLL", - "SECOND", - "SECTION", - "SELECT", - "SESSION", - "SESSION_USER", - "SET", - "SIZE", - "SMALLINT", - "SOME", - "SPACE", - "SQL", - "SQLCA", - "SQLCODE", - "SQLERROR", - "SQLSTATE", - "SQLWARNING", - "SUBSTRING", - "SUM", - "SYSTEM_USER", - "TABLE", - "TEMPORARY", - "THEN", - "TIME", - "TIMESTAMP", - "TIMEZONE_HOUR", - "TIMEZONE_MINUTE", - "TO", - "TRAILING", - "TRANSACTION", - "TRANSLATE", - "TRANSLATION", - "TRIM", - "TRUE", - "UNION", - "UNIQUE", - "UNKNOWN", - "UPDATE", - "UPPER", - "USAGE", - "USER", - "USING", - "VALUE", - "VALUES", - "VARCHAR", - "VARYING", - "VIEW", - "WHEN", - "WHENEVER", - "WHERE", - "WITH", - "WORK", - "WRITE", - "YEAR", - "ZONE", -]; - -/// `SQL_KEYWORDS` (89): SQLite's own keywords, minus those ODBC already -/// reserves, as a comma-separated list. -/// -/// The list is read out of the linked SQLite library through -/// `sqlite3_keyword_count` / `sqlite3_keyword_name` rather than transcribed -/// from <https://www.sqlite.org/lang_keywords.html>. A hand-copied list would -/// describe whichever SQLite the author was reading about; this describes the -/// one the driver is actually linked against, and needs no maintenance when -/// that changes. It is the same reason the `ALTER TABLE` and outer-join -/// bitmaps are live-probed. -/// -/// `stackable-odbc-core` answers this info type with an empty string, which is -/// a valid empty list and says SQLite has no keywords of its own. It has -/// plenty — `AUTOINCREMENT`, `PRAGMA`, `VACUUM`, `GLOB`, `REGEXP` — and -/// applications read this to decide which identifiers need quoting, so an -/// empty list can leave a generated identifier unquoted where it collides. -/// -/// Computed once: the underlying table is fixed at link time. -fn sqlite_specific_keywords() -> &'static str { - static KEYWORDS: std::sync::OnceLock<String> = std::sync::OnceLock::new(); - KEYWORDS.get_or_init(|| { - let count = unsafe { rusqlite::ffi::sqlite3_keyword_count() }; - let mut names: Vec<&'static str> = Vec::with_capacity(count.max(0) as usize); - - for i in 0..count { - let mut ptr: *const std::ffi::c_char = std::ptr::null(); - let mut len: std::ffi::c_int = 0; - // SAFETY: `i` is in `0..sqlite3_keyword_count()`, the range the API - // defines. On success it writes a pointer into SQLite's own static - // keyword table, valid for the life of the process, and its length; - // neither is owned by the caller, so the `'static` borrow is sound. - let rc = unsafe { rusqlite::ffi::sqlite3_keyword_name(i, &mut ptr, &mut len) }; - if rc != rusqlite::ffi::SQLITE_OK || ptr.is_null() || len <= 0 { - continue; - } - // SAFETY: as above — `ptr`/`len` describe a live, static, ASCII - // keyword that SQLite never mutates or frees. - let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }; - let Ok(name) = std::str::from_utf8(bytes) else { - continue; - }; - if !ODBC_RESERVED_KEYWORDS - .iter() - .any(|r| r.eq_ignore_ascii_case(name)) - { - names.push(name); +/// Cached behind a `OnceLock` because core recomputes `SQL_KEYWORDS` on every +/// call — it cannot cache a value that is generic over the backend — and +/// walking SQLite's keyword table each time would be wasteful. The table is +/// fixed at link time, so one walk is enough. +pub(crate) fn sqlite_keywords() -> &'static [&'static str] { + static KEYWORDS: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new(); + KEYWORDS + .get_or_init(|| { + let count = unsafe { rusqlite::ffi::sqlite3_keyword_count() }; + let mut names: Vec<&'static str> = Vec::with_capacity(count.max(0) as usize); + + for i in 0..count { + let mut ptr: *const std::ffi::c_char = std::ptr::null(); + let mut len: std::ffi::c_int = 0; + // SAFETY: `i` is in `0..sqlite3_keyword_count()`, the range the + // API defines. On success it writes a pointer into SQLite's own + // static keyword table, valid for the life of the process, and + // its length; neither is owned by the caller, so the `'static` + // borrow is sound. + let rc = unsafe { rusqlite::ffi::sqlite3_keyword_name(i, &mut ptr, &mut len) }; + if rc != rusqlite::ffi::SQLITE_OK || ptr.is_null() || len <= 0 { + continue; + } + // SAFETY: as above -- `ptr`/`len` describe a live, static, ASCII + // keyword that SQLite never mutates or frees. + let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }; + if let Ok(name) = std::str::from_utf8(bytes) { + names.push(name); + } } - } - // SQLite reports them in its own table order; sort so the value is - // stable for anything that diffs or caches it. - names.sort_unstable(); - names.join(",") - }) + names + }) + .as_slice() } pub(super) fn get_info_raw( @@ -1338,11 +1079,6 @@ pub(super) fn get_info_raw( // since 3.39.0; this build is 3.53.2). SQL_LIKE_ESCAPE_CLAUSE => Some(Ok(InfoValue::String("Y".into()))), SQL_OUTER_JOINS => Some(Ok(InfoValue::String("Y".into()))), - // SQLite's own keywords, read from the linked library. Core answers - // this with an empty string, which claims SQLite has none of its own. - SQL_KEYWORDS => Some(Ok(InfoValue::String( - sqlite_specific_keywords().to_string(), - ))), _ => common_get_info_raw::<SqliteBackend>(info_type).map(Ok), } } @@ -1486,8 +1222,8 @@ mod tests { SQL_FN_TD_EXTRACT, SQL_FN_TD_MONTH, SQL_FN_TD_MONTHNAME, SQL_FN_TD_QUARTER, SQL_FN_TD_TIMESTAMPADD, SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_YEAR, SQL_GB_NO_RELATION, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_INSENSITIVE, - SQL_MAX_CURSOR_NAME_LEN, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_OIC_CORE, SQL_SO_FORWARD_ONLY, - SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, + SQL_KEYWORDS, SQL_MAX_CURSOR_NAME_LEN, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_OIC_CORE, + SQL_SO_FORWARD_ONLY, SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, SQL_SP_MATCH_UNIQUE_PARTIAL, SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, @@ -1921,65 +1657,58 @@ mod tests { } } - /// `SQL_KEYWORDS` lists SQLite's own keywords and excludes the ones ODBC - /// already reserves. + /// The hook returns SQLite's **raw** keyword list, and core turns it into + /// `SQL_KEYWORDS` by subtracting the ODBC reserved words. /// - /// The list is read out of the linked library, so this asserts properties - /// rather than a fixed string: a `rusqlite` bump may legitimately add a - /// keyword, and pinning the exact value would turn that into a failure. + /// Both halves are asserted, because each can fail independently: a raw + /// list missing SQLite's own words, or a wiring mistake that leaves core + /// filtering something else. Properties rather than a fixed string — a + /// `rusqlite` bump may legitimately add a keyword, and pinning the value + /// would turn that into a failure. #[test] - fn sql_keywords_lists_sqlite_specific_keywords_only() { - let keywords = sqlite_specific_keywords(); - let listed: Vec<&str> = keywords.split(',').filter(|s| !s.is_empty()).collect(); + fn keywords_hook_feeds_sql_keywords_with_odbc_words_removed() { + let raw = SqliteBackend::keywords(); + assert!(!raw.is_empty(), "SQLite reserves words of its own"); + // Raw means unfiltered: ODBC's words are still in here, because + // removing them is core's job and doing it twice would be the + // duplication this hook exists to avoid. assert!( - !listed.is_empty(), - "SQLite has keywords of its own; an empty list is the claim core's \ - default made and this arm exists to correct" + raw.iter().any(|k| k.eq_ignore_ascii_case("SELECT")), + "the raw list should still contain SELECT; filtering is core's" ); - - // Present: unmistakably SQLite, and absent from the ODBC list. for expected in ["AUTOINCREMENT", "PRAGMA", "VACUUM", "GLOB", "REGEXP"] { assert!( - listed.contains(&expected), - "{expected} is a SQLite keyword but is missing from SQL_KEYWORDS" + raw.iter().any(|k| k.eq_ignore_ascii_case(expected)), + "{expected} is a SQLite keyword but the hook did not report it" ); } - // Absent: reserved by ODBC, so excluded by the spec's definition. + // And what an application actually receives, through the real path. + let params = ConnectParams::parse("Database=:memory:").unwrap(); + let conn = SqliteBackend::connect(&params).expect("connect"); + let value = match get_info_raw(&conn, SQL_KEYWORDS) { + Some(Ok(InfoValue::String(s))) => s, + other => panic!("SQL_KEYWORDS unexpected: {other:?}"), + }; + let listed: Vec<&str> = value.split(',').filter(|s| !s.is_empty()).collect(); + for reserved in ["SELECT", "FROM", "WHERE", "PRIMARY", "TABLE"] { assert!( !listed.contains(&reserved), - "{reserved} is an ODBC reserved keyword and must not appear in \ - SQL_KEYWORDS" + "{reserved} is ODBC-reserved and must not survive into SQL_KEYWORDS" ); } - - let mut sorted = listed.clone(); - sorted.sort_unstable(); - assert_eq!(listed, sorted, "SQL_KEYWORDS should be sorted"); - assert!( - !keywords.contains(", "), - "the spec asks for a comma-separated list, not comma-space" - ); - } - - /// The ODBC reserved list is transcribed from the specification page, so - /// the one mistake worth guarding is a duplicated entry from a bad merge. - #[test] - fn odbc_reserved_keywords_are_unique() { - let mut seen = std::collections::HashSet::new(); - for k in ODBC_RESERVED_KEYWORDS { + for expected in ["AUTOINCREMENT", "PRAGMA", "VACUUM", "GLOB", "REGEXP"] { assert!( - seen.insert(*k), - "{k} appears twice in ODBC_RESERVED_KEYWORDS" - ); - assert_eq!( - *k, - k.to_ascii_uppercase(), - "{k} should be uppercase, matching the spec page" + listed.contains(&expected), + "{expected} should survive the ODBC subtraction" ); } + assert!( + listed.len() < raw.len(), + "nothing was subtracted, so the ODBC filter did not run" + ); } /// SQLite's `GROUP BY` is unrelated to the select list, which is what From c5f78059e36ec7a51dc08ed57cb07ecd4a31497a Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 27 Jul 2026 15:42:36 +0200 Subject: [PATCH 23/50] feat!: adapt to the stackable-odbc-core interface changes Core's error handling, type safety and trait surface all moved; this crate no longer compiled against it. The migration is one commit because no intermediate state builds. Error handling. `SqliteError` gains an `Odbc` variant and `From<OdbcError>`, which is what `Backend::Error`'s new bound requires and what makes the round trip through core lossless. Every `Backend` and `StatementBackend` method now returns `Self::Error`, removing the double conversion that stood at some thirty-six call sites in `metadata.rs`. The classified variants keep the `rusqlite::Error` they were classified from, so `SQLGetDiagRec` reports SQLite's extended result code through `NativeErrorPtr` and the diagnostic message carries the whole causal chain -- every error previously reached the application as native code 0. Capabilities. `identifier_case` is implemented and the `get_info_raw` arm answering the same thing is gone; `SQL_GETDATA_EXTENSIONS` goes back to core, which is where a fact about core's own fetch path belongs. `get_functions` is derived from `CORE_EXPORTED_FUNCTIONS` rather than hand-listed: the list had drifted to 53 of the 69 exported entry points. Nullability. With rusqlite's `column_metadata`, each result column reports what it actually is -- `SQL_NO_NULLS`, `SQL_NULLABLE`, or `SQL_NULLABLE_UNKNOWN` for a computed column, where SQLite reports no metadata and the driver genuinely cannot tell. Every column was previously claimed nullable. Tests. The 37 reach-ins into `ConnectionHandle` are replaced with helpers that drive setup and read-back through the FFI on their own statement handle, so they cannot disturb the state a test is asserting on. `handles` is `pub(crate)` in core now; the `test-support` feature carries `conformance`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 55 +++ Cargo.toml | 18 +- benches/fetch_sqlite.rs | 50 ++- src/backend.rs | 278 +++++++++---- src/backend/execute.rs | 181 ++++++--- src/backend/info.rs | 552 ++++++++----------------- src/backend/metadata.rs | 141 +++---- src/escape_dialect.rs | 13 +- src/ffi_integration_tests.rs | 757 ++++++++++++++++------------------- 9 files changed, 992 insertions(+), 1053 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3021252..89ba861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Diagnostics now carry SQLite's own error code and the failure that caused + them. `map_sqlite_error` keeps the `rusqlite::Error` it classified rather + than flattening it into a message, so `SQLGetDiagRec` reports SQLite's + *extended* result code verbatim through `NativeErrorPtr` and the diagnostic + message includes the whole causal chain. Every error this driver produced + previously reached the application as native code `0`. The extended code is + the one worth having: it separates `SQLITE_CONSTRAINT_NOTNULL` (1299) from + `SQLITE_CONSTRAINT_FOREIGNKEY` (787), which the primary code and SQLSTATE + both report identically as a constraint violation. + +- `SQLColAttribute` answers `SQL_DESC_BASE_TABLE_NAME` for a column that comes + from a stored table, read from `sqlite3_table_column_metadata`. The catalog + and schema stay empty, because this driver reports that it has neither. + - Initial extraction of `stackable-odbc-sqlite` into its own repository, from the `stackable-odbc-rs` workspace it was developed in. Provides the ODBC driver for SQLite: the `Backend` and `StatementBackend` implementations, @@ -18,6 +32,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `SQLDescribeCol` and `SQLColAttribute` report each result column's real + nullability instead of claiming every column is nullable. A column declared + `NOT NULL` is now `SQL_NO_NULLS`, a plain table column `SQL_NULLABLE`, and a + computed column — an expression, a literal, an aggregate — + `SQL_NULLABLE_UNKNOWN`. The third is the point: `sqlite3_table_column_metadata` + reports nothing for a computed column, so the driver genuinely cannot + determine the answer, and the spec has a value for exactly that rather than + requiring a guess. Guessing is not harmless in either direction: + `SQL_NO_NULLS` tells an application it may skip a NULL check it needs, and + `SQL_NULLABLE` makes it write one it does not. Requires `rusqlite`'s + `column_metadata` feature. + +- `SQLGetFunctions` reports every function the driver actually exports, derived + from `stackable-odbc-core`'s `CORE_EXPORTED_FUNCTIONS`, rather than a + hand-written list. The list had drifted to 53 of the 69 exported entry + points, so sixteen the driver does export were reported as unsupported — + including `SQLAllocConnect`, `SQLTransact`, `SQLExtendedFetch` and the + descriptor-field functions. It over-claimed nothing, which is the direction + that matters: `SQLGetFunctions` is what the Windows Driver Manager builds its + dispatch table from, so naming a function core does not export would hand it + a null pointer. A test keeps the historical list checked against core's. + +- `SQLSetStmtAttr(SQL_ATTR_CURSOR_TYPE)` with an unsupported cursor type now + substitutes `SQL_CURSOR_FORWARD_ONLY` and returns `SQL_SUCCESS_WITH_INFO` + with SQLSTATE `01S02` ("option value changed"), where it previously failed + with `HYC00`. The substituted value is readable back through + `SQLGetStmtAttr`, which is how an application learns what it was given. This + follows a `stackable-odbc-core` change; the driver's behaviour is unchanged + beyond what it reports. + +- `SQL_IDENTIFIER_CASE` is now declared through the backend's + `identifier_case` hook rather than answered directly. The value is unchanged + (`SQL_IC_MIXED`): SQLite stores an unquoted identifier as written and matches + it case-insensitively. Answering it in one place removes the possibility of + the hook and the direct answer disagreeing. + +- `SQL_GETDATA_EXTENSIONS` is no longer answered by this driver. The value is + unchanged, and is now `stackable-odbc-core`'s to state: it describes what + core's own fetch path supports, not anything about SQLite, and this driver + could not keep it correct if that path changed. + - `SQL_CURSOR_COMMIT_BEHAVIOR` now reports `SQL_CB_PRESERVE` instead of `SQL_CB_DELETE`, and `SQL_CURSOR_ROLLBACK_BEHAVIOR` is now declared rather than left to a fallback. Both report `SQL_CB_PRESERVE`. The driver diff --git a/Cargo.toml b/Cargo.toml index 958c632..c08bd9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,18 @@ crate-type = ["cdylib", "rlib"] [dependencies] # "limits" is dependency-free; it exposes sqlite3_limit, which SQL_MAX_* is # read from rather than hardcoded. -rusqlite = { version = "0.40", features = ["bundled", "column_decltype", "limits"] } +# +# "column_metadata" exposes sqlite3_table_column_metadata, which is the only +# way to answer SQLDescribeCol's nullability and SQL_DESC_BASE_TABLE_NAME +# truthfully: it reports a result column's originating table and its NOT NULL +# constraint, and reports neither for a computed column -- which is exactly the +# SQL_NULLABLE_UNKNOWN case. +rusqlite = { version = "0.40", features = [ + "bundled", + "column_decltype", + "column_metadata", + "limits", +] } snafu = "0.9" # TODO: switch to a crates.io version dep once stackable-odbc-core is published. stackable-odbc-core = { path = "../stackable-odbc-core" } @@ -26,6 +37,11 @@ tracing = "0.1" [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } proptest = "1" +# "test-support" gates core's `conformance` module and the connection +# attach/detach helpers. Default-off there because it is test code that would +# otherwise land in this driver's shipped binary; enabled only here, so +# `cargo test` sees it and `cargo build` does not. +stackable-odbc-core = { path = "../stackable-odbc-core", features = ["test-support"] } [lints.clippy] unwrap_in_result = "deny" diff --git a/benches/fetch_sqlite.rs b/benches/fetch_sqlite.rs index 80309ca..37c8d3d 100644 --- a/benches/fetch_sqlite.rs +++ b/benches/fetch_sqlite.rs @@ -24,7 +24,6 @@ use std::time::Duration; use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use stackable_odbc_core::ffi; -use stackable_odbc_core::handles::{ConnectionHandle, as_handle_ref}; use stackable_odbc_core::types::{CDataType, HandleType, SqlReturn}; use stackable_odbc_sqlite::SqliteBackend; @@ -126,14 +125,37 @@ unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { } } -/// Run a SQL statement directly on the underlying rusqlite Connection (bypasses -/// the FFI exec path so we can do bulk inserts without the ODBC dispatch). -unsafe fn rusqlite_exec(conn: *mut c_void, sql: &str) { +/// Run setup SQL on `conn` through `SQLExecDirect`. +/// +/// This used to reach into `ConnectionHandle` for the underlying +/// `rusqlite::Connection` to bypass ODBC dispatch; core's `handles` module is +/// `pub(crate)` now, and the bypass bought nothing measurable anyway. Every +/// setup here is three statements — `DROP`, `CREATE` and one bulk `INSERT` +/// whose rows are generated by a recursive CTE inside SQLite — so the ODBC +/// dispatch is paid three times, not once per row. Setup runs outside the +/// measured section regardless. +/// +/// `SQLExecDirect` executes one statement, hence the split on `;`; none of the +/// generated setup SQL contains a `;` inside a string literal. +unsafe fn exec_setup(conn: *mut c_void, sql: &str) { unsafe { - let h = as_handle_ref::<ConnectionHandle<SqliteBackend>>(conn).expect("valid conn"); - let s = h.connection.as_ref().expect("connected"); - let db = s.conn.lock().expect("lock"); - db.execute_batch(sql).expect("setup sql"); + let mut stmt: *mut c_void = std::ptr::null_mut(); + let ret = ffi::handle::sql_alloc_handle::<SqliteBackend>( + HandleType::Stmt as i16, + conn, + &mut stmt, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "alloc setup stmt"); + for one in sql.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let wide: Vec<u16> = one.encode_utf16().collect(); + let ret = ffi::execute::sql_exec_direct_w::<SqliteBackend>( + stmt, + wide.as_ptr(), + wide.len() as i32, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "setup sql: {one}"); + } + let _ = ffi::handle::sql_free_handle::<SqliteBackend>(HandleType::Stmt as i16, stmt); } } @@ -317,7 +339,7 @@ fn bench_shape_a_late_binding(c: &mut Criterion) { let (env, conn, stmt) = unsafe { alloc_handles() }; assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); unsafe { - rusqlite_exec(conn, &setup_sql); + exec_setup(conn, &setup_sql); } let mut group = c.benchmark_group("sqlite/shape_a"); @@ -351,7 +373,7 @@ fn bench_shape_b_late_binding(c: &mut Criterion) { let (env, conn, stmt) = unsafe { alloc_handles() }; assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); unsafe { - rusqlite_exec(conn, &setup_sql); + exec_setup(conn, &setup_sql); } let mut group = c.benchmark_group("sqlite/shape_b"); @@ -384,7 +406,7 @@ fn bench_shape_a_bound(c: &mut Criterion) { let (env, conn, stmt) = unsafe { alloc_handles() }; assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); unsafe { - rusqlite_exec(conn, &setup_sql); + exec_setup(conn, &setup_sql); } // Bind columns once, before the bench loop. Bindings survive SQLCloseCursor @@ -426,7 +448,7 @@ fn bench_shape_b_bound(c: &mut Criterion) { let (env, conn, stmt) = unsafe { alloc_handles() }; assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); unsafe { - rusqlite_exec(conn, &setup_sql); + exec_setup(conn, &setup_sql); } // Bind columns once, before the bench loop. Bindings survive SQLCloseCursor @@ -469,7 +491,7 @@ fn bench_shape_a_repeat(c: &mut Criterion) { let (env, conn, stmt) = unsafe { alloc_handles() }; assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); unsafe { - rusqlite_exec(conn, &setup_sql); + exec_setup(conn, &setup_sql); } let mut group = c.benchmark_group("sqlite/shape_a"); @@ -505,7 +527,7 @@ fn bench_shape_b_repeat(c: &mut Criterion) { let (env, conn, stmt) = unsafe { alloc_handles() }; assert_eq!(unsafe { connect_memory(conn) }, SqlReturn::SUCCESS); unsafe { - rusqlite_exec(conn, &setup_sql); + exec_setup(conn, &setup_sql); } let mut group = c.benchmark_group("sqlite/shape_b"); diff --git a/src/backend.rs b/src/backend.rs index 975eef7..7402959 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -6,7 +6,7 @@ use stackable_odbc_core::{ errors::OdbcError, types::{ ColumnDescriptor, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, InfoValue, - SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, SQL_NC_LOW, SQL_NNC_NON_NULL, + SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TXN_SERIALIZABLE, TypeInfoRow, }, }; @@ -89,6 +89,16 @@ impl SqliteStatement { #[derive(Debug, Snafu)] pub enum SqliteError { + /// An [`OdbcError`] core itself produced, carried unchanged. + /// + /// `Backend::Error` is bounded by `From<OdbcError>` so that a defaulted + /// trait body can construct an error and still name `Self::Error`. This + /// variant is how such an error travels back to core with its SQLSTATE, + /// native error code and causal chain intact — classifying it a second + /// time would flatten all three. + #[snafu(display("{source}"))] + Odbc { source: OdbcError }, + #[snafu(display("SQLite error: {source}"))] Rusqlite { source: rusqlite::Error }, #[snafu(display("Missing parameter: {name}"))] @@ -99,22 +109,71 @@ pub enum SqliteError { General { message: String }, // --- Classified variants produced by `map_sqlite_error` --- + // + // Each carries the `rusqlite::Error` it was classified from, so the + // conversion to `OdbcError` can report SQLite's own extended result code + // through `SQLGetDiagRec`'s `NativeErrorPtr` and preserve the causal chain + // rather than flattening it into the message. `None` is for the classes + // rusqlite raises itself, which have no SQLite result code behind them. + // + // The field is named `cause`, not `source`, because `snafu` special-cases + // a field called `source` and requires it to implement `std::error::Error` + // directly — which `Option<rusqlite::Error>` does not. #[snafu(display("unable to open database: {message}"))] - ConnectionFailed { message: String }, + ConnectionFailed { + message: String, + cause: Option<rusqlite::Error>, + }, #[snafu(display("integrity constraint violation: {message}"))] - ConstraintViolation { message: String }, + ConstraintViolation { + message: String, + cause: Option<rusqlite::Error>, + }, #[snafu(display("syntax error or access violation: {message}"))] - SyntaxError { message: String }, + SyntaxError { + message: String, + cause: Option<rusqlite::Error>, + }, #[snafu(display("table or view not found: {message}"))] - TableNotFound { message: String }, + TableNotFound { + message: String, + cause: Option<rusqlite::Error>, + }, #[snafu(display("column not found: {message}"))] - ColumnNotFound { message: String }, + ColumnNotFound { + message: String, + cause: Option<rusqlite::Error>, + }, #[snafu(display("database is busy: {message}"))] - DatabaseBusy { message: String }, + DatabaseBusy { + message: String, + cause: Option<rusqlite::Error>, + }, #[snafu(display("data type mismatch: {message}"))] - DataTypeMismatch { message: String }, + DataTypeMismatch { + message: String, + cause: Option<rusqlite::Error>, + }, #[snafu(display("numeric value out of range: {message}"))] - NumericOutOfRange { message: String }, + NumericOutOfRange { + message: String, + cause: Option<rusqlite::Error>, + }, +} + +/// SQLite's extended result code for `e`, or `0` when there is none. +/// +/// The extended code is the useful one: it distinguishes +/// `SQLITE_CONSTRAINT_FOREIGNKEY` (787) from `SQLITE_CONSTRAINT_NOTNULL` (1299) +/// where the primary code says only `SQLITE_CONSTRAINT` (19). ODBC defines `0` +/// as "no data-source code", which is the right answer for the failures +/// rusqlite raises without ever reaching SQLite. +fn sqlite_extended_code(e: &rusqlite::Error) -> i32 { + match e { + rusqlite::Error::SqliteFailure(ffi_err, _) => ffi_err.extended_code, + rusqlite::Error::SqlInputError { error, .. } => error.extended_code, + _ => 0, + } } /// Central mapping from `rusqlite` errors to [`SqliteError`]. @@ -133,16 +192,26 @@ pub(crate) fn map_sqlite_error(e: rusqlite::Error) -> SqliteError { rusqlite::Error::SqliteFailure(ref ffi_err, ref msg) => { let message = msg.clone().unwrap_or_else(|| e.to_string()); match ffi_err.code { - ErrorCode::ConstraintViolation => SqliteError::ConstraintViolation { message }, + ErrorCode::ConstraintViolation => SqliteError::ConstraintViolation { + message, + cause: Some(e), + }, ErrorCode::CannotOpen | ErrorCode::NotADatabase | ErrorCode::PermissionDenied => { - SqliteError::ConnectionFailed { message } - } - ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked => { - SqliteError::DatabaseBusy { message } + SqliteError::ConnectionFailed { + message, + cause: Some(e), + } } - ErrorCode::TypeMismatch => SqliteError::DataTypeMismatch { message }, + ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked => SqliteError::DatabaseBusy { + message, + cause: Some(e), + }, + ErrorCode::TypeMismatch => SqliteError::DataTypeMismatch { + message, + cause: Some(e), + }, // SQLITE_ERROR covers syntax errors and unresolved names alike. - ErrorCode::Unknown => classify_sqlite_error_message(message), + ErrorCode::Unknown => classify_sqlite_error_message(message, Some(e)), _ => SqliteError::Rusqlite { source: e }, } } @@ -151,24 +220,36 @@ pub(crate) fn map_sqlite_error(e: rusqlite::Error) -> SqliteError { // same: SQLITE_ERROR with a message that names the failure. rusqlite::Error::SqlInputError { ref error, ref msg, .. - } => match error.code { - ErrorCode::ConstraintViolation => SqliteError::ConstraintViolation { - message: msg.clone(), - }, - ErrorCode::Unknown => classify_sqlite_error_message(msg.clone()), - _ => SqliteError::Rusqlite { source: e }, - }, - // Errors rusqlite raises itself, without a SQLite result code. - rusqlite::Error::InvalidColumnName(ref name) => SqliteError::ColumnNotFound { - message: format!("no such column: {name}"), - }, + } => { + let message = msg.clone(); + match error.code { + ErrorCode::ConstraintViolation => SqliteError::ConstraintViolation { + message, + cause: Some(e), + }, + ErrorCode::Unknown => classify_sqlite_error_message(message, Some(e)), + _ => SqliteError::Rusqlite { source: e }, + } + } + // Errors rusqlite raises itself, without a SQLite result code — so + // there is no extended code to carry, but the error itself is still + // worth preserving as the cause. + rusqlite::Error::InvalidColumnName(ref name) => { + let message = format!("no such column: {name}"); + SqliteError::ColumnNotFound { + message, + cause: Some(e), + } + } rusqlite::Error::InvalidColumnType(..) | rusqlite::Error::FromSqlConversionFailure(..) => { SqliteError::DataTypeMismatch { message: e.to_string(), + cause: Some(e), } } rusqlite::Error::IntegralValueOutOfRange(..) => SqliteError::NumericOutOfRange { message: e.to_string(), + cause: Some(e), }, other => SqliteError::Rusqlite { source: other }, } @@ -176,13 +257,24 @@ pub(crate) fn map_sqlite_error(e: rusqlite::Error) -> SqliteError { /// Split a `SQLITE_ERROR` message into the SQLSTATE classes the ODBC spec /// distinguishes. SQLite's wording for these is stable across versions. -fn classify_sqlite_error_message(message: String) -> SqliteError { +fn classify_sqlite_error_message(message: String, cause: Option<rusqlite::Error>) -> SqliteError { if message.starts_with("no such table") || message.starts_with("no such view") { - SqliteError::TableNotFound { message } + SqliteError::TableNotFound { message, cause } } else if message.starts_with("no such column") { - SqliteError::ColumnNotFound { message } + SqliteError::ColumnNotFound { message, cause } } else { - SqliteError::SyntaxError { message } + SqliteError::SyntaxError { message, cause } + } +} + +/// Carries an [`OdbcError`] core produced without reclassifying it. +/// +/// Required by `Backend::Error`'s `From<OdbcError>` bound. Paired with the +/// [`SqliteError::Odbc`] arm of the reverse conversion, the round trip is +/// lossless. +impl From<OdbcError> for SqliteError { + fn from(source: OdbcError) -> Self { + SqliteError::Odbc { source } } } @@ -190,31 +282,50 @@ impl From<SqliteError> for OdbcError { fn from(e: SqliteError) -> Self { use stackable_odbc_core::types::SqlState; - let sqlstate = match &e { + // Taken before the match moves `e`. + let message = e.to_string(); + + let (sqlstate, cause) = match e { + // Already an `OdbcError`; hand it straight back. See the variant. + SqliteError::Odbc { source } => return source, SqliteError::NotImplemented { feature } => { - return OdbcError::NotImplemented { - feature: feature.clone(), - }; + return OdbcError::NotImplemented { feature }; + } + SqliteError::ConnectionFailed { cause, .. } => { + (SqlState::client_unable_to_establish_connection(), cause) + } + SqliteError::ConstraintViolation { cause, .. } => { + (SqlState::integrity_constraint_violation(), cause) + } + SqliteError::SyntaxError { cause, .. } => { + (SqlState::syntax_error_or_access_violation(), cause) } - SqliteError::ConnectionFailed { .. } => { - SqlState::client_unable_to_establish_connection() + SqliteError::TableNotFound { cause, .. } => { + (SqlState::base_table_or_view_not_found(), cause) } - SqliteError::ConstraintViolation { .. } => SqlState::integrity_constraint_violation(), - SqliteError::SyntaxError { .. } => SqlState::syntax_error_or_access_violation(), - SqliteError::TableNotFound { .. } => SqlState::base_table_or_view_not_found(), - SqliteError::ColumnNotFound { .. } => SqlState::column_not_found(), - SqliteError::DatabaseBusy { .. } => SqlState::timeout_expired(), - SqliteError::DataTypeMismatch { .. } => { - SqlState::restricted_data_type_attribute_violation() + SqliteError::ColumnNotFound { cause, .. } => (SqlState::column_not_found(), cause), + SqliteError::DatabaseBusy { cause, .. } => (SqlState::timeout_expired(), cause), + SqliteError::DataTypeMismatch { cause, .. } => { + (SqlState::restricted_data_type_attribute_violation(), cause) + } + SqliteError::NumericOutOfRange { cause, .. } => { + (SqlState::numeric_value_out_of_range(), cause) + } + SqliteError::Rusqlite { source } => (SqlState::general_error(), Some(source)), + SqliteError::MissingParam { .. } | SqliteError::General { .. } => { + (SqlState::general_error(), None) } - SqliteError::NumericOutOfRange { .. } => SqlState::numeric_value_out_of_range(), - SqliteError::Rusqlite { .. } - | SqliteError::MissingParam { .. } - | SqliteError::General { .. } => SqlState::general_error(), }; - OdbcError::General { - message: e.to_string(), - sqlstate, + + // `SQLGetDiagRec` reports the native code through `NativeErrorPtr` and + // walks the causal chain into the diagnostic message. Both were + // dropped on the floor before: every SQLite error reached the + // application as native code 0 with its inner links flattened away. + let native_error = cause.as_ref().map_or(0, sqlite_extended_code); + let err = OdbcError::general(message, sqlstate).with_native_error(native_error); + match cause { + Some(cause) => err.with_source(cause), + None => err, } } } @@ -267,44 +378,35 @@ impl Backend for SqliteBackend { /// Manual-commit mode is entered by opening a transaction with `BEGIN`; /// `end_tran` then commits or rolls it back and, while still in /// manual-commit mode, opens the next one. - fn set_autocommit(conn: &SqliteConnection, enabled: bool) -> Result<(), OdbcError> { - let db = conn.conn.lock().map_err(|e| { - OdbcError::general( - format!("Mutex poisoned: {e}"), - stackable_odbc_core::types::SqlState::general_error(), - ) + fn set_autocommit(conn: &SqliteConnection, enabled: bool) -> Result<(), SqliteError> { + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), })?; if enabled { // Returning to autocommit commits any open transaction, per the // ODBC spec: "Any open transactions on the connection are committed // when SQL_ATTR_AUTOCOMMIT is set to SQL_AUTOCOMMIT_ON". if !db.is_autocommit() { - db.execute_batch("COMMIT") - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + db.execute_batch("COMMIT").map_err(map_sqlite_error)?; } } else if db.is_autocommit() { - db.execute_batch("BEGIN") - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + db.execute_batch("BEGIN").map_err(map_sqlite_error)?; } conn.manual_commit .store(!enabled, std::sync::atomic::Ordering::Relaxed); Ok(()) } - fn end_tran(conn: &SqliteConnection, commit: bool) -> Result<(), OdbcError> { - let db = conn.conn.lock().map_err(|e| { - OdbcError::general( - format!("Mutex poisoned: {e}"), - stackable_odbc_core::types::SqlState::general_error(), - ) + fn end_tran(conn: &SqliteConnection, commit: bool) -> Result<(), SqliteError> { + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), })?; // If SQLite is in autocommit mode there is no open transaction to commit/roll back. if db.is_autocommit() { return Ok(()); } let sql = if commit { "COMMIT" } else { "ROLLBACK" }; - db.execute_batch(sql) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + db.execute_batch(sql).map_err(map_sqlite_error)?; // Still in manual-commit mode: open the next transaction, otherwise // subsequent statements would silently autocommit. @@ -312,8 +414,7 @@ impl Backend for SqliteBackend { .manual_commit .load(std::sync::atomic::Ordering::Relaxed) { - db.execute_batch("BEGIN") - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + db.execute_batch("BEGIN").map_err(map_sqlite_error)?; } Ok(()) } @@ -347,6 +448,27 @@ impl Backend for SqliteBackend { CursorBehavior::Preserve } + /// `SQL_IC_MIXED`: SQLite stores an unquoted identifier with the case it + /// was written in, and matches it case-insensitively. + /// + /// `SQL_IC_MIXED` is the spec's value for exactly that pair — "stored in + /// mixed case and case-insensitive" — as opposed to `SQL_IC_UPPER` / + /// `SQL_IC_LOWER`, which fold the stored name, and `SQL_IC_SENSITIVE`, + /// which would make `SELECT * FROM T` and `SELECT * FROM t` name different + /// tables. They do not. + /// + /// Case-insensitive matching is ASCII-only in SQLite unless the build + /// carries ICU; that does not change the answer, since ODBC has no value + /// for "case-insensitive for some characters". + /// + /// Distinct from `SQL_QUOTED_IDENTIFIER_CASE`, which core answers, and + /// which is `SQL_IC_SENSITIVE` here: a quoted `"T"` does not match `"t"`. + /// + /// <https://sqlite.org/lang_keywords.html> + fn identifier_case() -> u16 { + SQL_IC_MIXED + } + /// SQLite has no ODBC catalogs: `metadata::tables` reports `TABLE_CAT` as /// NULL for every row, and a `catalog = "%"` enumeration returns an empty /// result set. @@ -577,7 +699,7 @@ impl Backend for SqliteBackend { fn get_info_pre_connect( info_type: stackable_odbc_core::types::InfoType, - ) -> Result<InfoValue, OdbcError> { + ) -> Result<InfoValue, SqliteError> { info::get_info_pre_connect(info_type) } @@ -621,7 +743,7 @@ impl Backend for SqliteBackend { catalog: Option<&str>, schema: Option<&str>, table: Option<&str>, - ) -> Result<SqliteStatement, OdbcError> { + ) -> Result<SqliteStatement, SqliteError> { metadata::primary_keys(conn, catalog, schema, table) } @@ -633,7 +755,7 @@ impl Backend for SqliteBackend { fk_catalog: Option<&str>, fk_schema: Option<&str>, fk_table: Option<&str>, - ) -> Result<SqliteStatement, OdbcError> { + ) -> Result<SqliteStatement, SqliteError> { metadata::foreign_keys( conn, pk_catalog, pk_schema, pk_table, fk_catalog, fk_schema, fk_table, ) @@ -645,7 +767,7 @@ impl Backend for SqliteBackend { schema: Option<&str>, table: Option<&str>, unique_only: bool, - ) -> Result<SqliteStatement, OdbcError> { + ) -> Result<SqliteStatement, SqliteError> { metadata::statistics(conn, catalog, schema, table, unique_only) } @@ -657,7 +779,7 @@ impl Backend for SqliteBackend { table: Option<&str>, scope: stackable_odbc_core::types::Scope, nullable: stackable_odbc_core::types::Nullable, - ) -> Result<SqliteStatement, OdbcError> { + ) -> Result<SqliteStatement, SqliteError> { metadata::special_columns( conn, identifier_type, @@ -795,8 +917,10 @@ mod tests { let Err(err) = SqliteBackend::end_tran(&conn, true) else { panic!("COMMIT should have failed the deferred foreign-key constraint"); }; + // `end_tran` reports the backend's own error type now; the SQLSTATE an + // application sees is the one the conversion to `OdbcError` assigns. assert_eq!( - err.sqlstate().as_str(), + OdbcError::from(err).sqlstate().as_str(), sql_state::INTEGRITY_CONSTRAINT_VIOLATION ); } diff --git a/src/backend/execute.rs b/src/backend/execute.rs index 37ba6cb..a70b58d 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -6,7 +6,7 @@ use stackable_odbc_core::backend::StatementBackend; use stackable_odbc_core::errors::OdbcError; use stackable_odbc_core::types::{ - CDataType, ColumnDescriptor, ColumnValue, ExecuteOutcome, FetchResult, + CDataType, ColumnDescriptor, ColumnValue, ExecuteOutcome, FetchResult, Nullable, }; use super::info::sqlite_bare_type_name; @@ -16,6 +16,74 @@ use crate::type_conversion::{ sqlite_type_to_sql_data_type, sqlite_value_to_column_value, }; +/// Builds the ODBC descriptor for result column `i` of `stmt`. +/// +/// Nullability and the originating table come from +/// `sqlite3_table_column_metadata`, which SQLite answers only for a column +/// that is a plain reference to a stored table column. For a computed +/// column — an expression, a literal, an aggregate — it reports nothing, and +/// that is precisely `SQL_NULLABLE_UNKNOWN`: the driver cannot determine +/// whether the column admits NULL, and the spec's third value says exactly +/// that instead of guessing one of the other two. Guessing is not harmless in +/// either direction: `SQL_NO_NULLS` tells an application it may skip a NULL +/// check it needs, and `SQL_NULLABLE` makes it write one it does not. +/// +/// The catalog and schema stay empty even though SQLite names a database for +/// the column. This driver reports `supports_catalogs() == false` and +/// `supports_schemas() == false`, so naming either here would contradict what +/// it tells applications everywhere else — `metadata::tables` reports +/// `TABLE_CAT` and `TABLE_SCHEM` as NULL for every row. +fn describe_column( + stmt: &rusqlite::Statement<'_>, + i: usize, + col: &rusqlite::Column<'_>, +) -> ColumnDescriptor { + let name = stmt + .column_name(i) + .map(|n| n.to_string()) + .unwrap_or_else(|_| "?".to_string()); + let decl = col.decl_type().unwrap_or("TEXT").to_string(); + let sql_type = sqlite_type_to_sql_data_type(&decl); + + let descriptor = ColumnDescriptor::new(name, sql_type) + .with_precision_scale( + sqlite_declared_type_precision(&decl), + sqlite_declared_type_scale(&decl), + ) + // Spec (SQL_DESC_TYPE_NAME / SQLColumns.TYPE_NAME): both list bare + // examples ("CHAR", "VARCHAR", ...), not declarations, so `decl` + // ("VARCHAR(50)") matches no `SQLGetTypeInfo` row. + // `sqlite_bare_type_name` returns the bare name that does (see its doc + // comment in `backend/info.rs`); the declared length is not lost, only + // moved out of the name — it is still carried as the precision above. + .with_type_name(sqlite_bare_type_name(sql_type)); + + // `Ok(None)` is a computed column and `Err` is SQLite failing to resolve a + // name it just reported. Both leave the descriptor's nullability at the + // `SQL_NULLABLE_UNKNOWN` that `ColumnDescriptor::new` starts from, which + // is the honest answer in either case. + let Ok(Some((_db, table, _origin, _decl_type, _coll_seq, not_null, _pk, _autoinc))) = + stmt.column_metadata(i) + else { + return descriptor; + }; + + let descriptor = descriptor.with_nullable(if not_null { + Nullable::SqlNoNulls + } else { + Nullable::SqlNullable + }); + + // A table name SQLite reports but that is not UTF-8 is left unset rather + // than lossily transcoded: `SQL_DESC_BASE_TABLE_NAME` is what an + // application uses to build further SQL, and a mangled identifier there is + // worse than none. + match table.to_str() { + Ok(table) => descriptor.with_origin("", "", table), + Err(_) => descriptor, + } +} + pub(super) fn exec_direct( conn: &SqliteConnection, sql: &str, @@ -40,32 +108,7 @@ pub(super) fn exec_direct( let columns: Vec<ColumnDescriptor> = sqlite_columns .iter() .enumerate() - .map(|(i, col)| { - let name = stmt - .column_name(i) - .map(|n| n.to_string()) - .unwrap_or_else(|_| "?".to_string()); - let decl = col.decl_type().unwrap_or("TEXT").to_string(); - let sql_type = sqlite_type_to_sql_data_type(&decl); - ColumnDescriptor { - name, - sql_type, - precision: sqlite_declared_type_precision(&decl), - scale: sqlite_declared_type_scale(&decl), - // Spec (SQL_DESC_TYPE_NAME / SQLColumns.TYPE_NAME): both list - // bare examples ("CHAR", "VARCHAR", ...), not declarations, so - // `decl` ("VARCHAR(50)") matches no `SQLGetTypeInfo` row. - // `sqlite_bare_type_name` returns the bare name that does - // (see its doc comment in `backend/info.rs`); the declared - // length is not lost, only moved out of the name; it is still - // carried above via `sqlite_declared_type_precision`. - type_name: sqlite_bare_type_name(sql_type).to_string(), - // Result-set columns are reported as nullable: a prepared - // SELECT exposes no per-column NOT NULL metadata, so the driver - // does not attempt to distinguish non-nullable columns here. - nullable: true, - } - }) + .map(|(i, col)| describe_column(&stmt, i, col)) .collect(); // Eagerly fetch all rows @@ -149,23 +192,7 @@ pub(super) fn execute( let columns: Vec<ColumnDescriptor> = sqlite_columns .iter() .enumerate() - .map(|(i, col)| { - let name = prepared - .column_name(i) - .map(|n| n.to_string()) - .unwrap_or_else(|_| "?".to_string()); - let decl = col.decl_type().unwrap_or("TEXT").to_string(); - let sql_type = sqlite_type_to_sql_data_type(&decl); - ColumnDescriptor { - name, - sql_type, - precision: sqlite_declared_type_precision(&decl), - scale: sqlite_declared_type_scale(&decl), - // See the `exec_direct` block above for why this is not `decl`. - type_name: sqlite_bare_type_name(sql_type).to_string(), - nullable: true, - } - }) + .map(|(i, col)| describe_column(&prepared, i, col)) .collect(); let col_count = prepared.column_count(); @@ -191,7 +218,9 @@ pub(super) fn execute( } impl StatementBackend for SqliteStatement { - fn fetch(&mut self) -> Result<FetchResult, OdbcError> { + type Error = SqliteError; + + fn fetch(&mut self) -> Result<FetchResult, SqliteError> { self.cursor += 1; if (self.cursor as usize) < self.rows.len() { Ok(FetchResult::Row) @@ -204,10 +233,10 @@ impl StatementBackend for SqliteStatement { &mut self, col: u16, _target_type: CDataType, - ) -> Result<std::borrow::Cow<'_, ColumnValue>, OdbcError> { + ) -> Result<std::borrow::Cow<'_, ColumnValue>, SqliteError> { use stackable_odbc_core::types::SqlState; if self.cursor < 0 || self.cursor as usize >= self.rows.len() { - return Err(OdbcError::NoResultSet); + return Err(OdbcError::NoResultSet.into()); } let col_idx = (col as usize).checked_sub(1).ok_or_else(|| { OdbcError::general("Column index must be >= 1", SqlState::general_error()) @@ -224,32 +253,58 @@ impl StatementBackend for SqliteStatement { ), SqlState::general_error(), ) + .into() }) } - fn column_count(&self) -> u16 { - self.columns.len() as u16 + /// `i16` because `SQLNumResultCols` writes through a `SQLSMALLINT *`. + /// + /// The clamp is unreachable: `SQLITE_LIMIT_COLUMN` cannot be raised above + /// 32767, which is exactly `i16::MAX`, so a materialised result set can + /// never carry more columns than this type can name. + fn column_count(&self) -> i16 { + i16::try_from(self.columns.len()).unwrap_or(i16::MAX) } - fn describe_col(&self, col: u16) -> Result<ColumnDescriptor, OdbcError> { + fn describe_col( + &self, + col: u16, + ) -> Result<std::borrow::Cow<'_, ColumnDescriptor>, SqliteError> { use stackable_odbc_core::types::SqlState; let idx = (col as usize).checked_sub(1).ok_or_else(|| { OdbcError::general("Column index must be >= 1", SqlState::general_error()) })?; - self.columns.get(idx).cloned().ok_or_else(|| { - OdbcError::general( - format!("Column {} out of range", col), - SqlState::general_error(), - ) - }) + self.columns + .get(idx) + .map(std::borrow::Cow::Borrowed) + .ok_or_else(|| { + OdbcError::general( + format!("Column {} out of range", col), + SqlState::general_error(), + ) + .into() + }) } - fn row_count(&self) -> Option<usize> { - Some(self.affected_rows.unwrap_or(self.rows.len())) + /// `i64` because `SQLRowCount` writes through a signed `SQLLEN *`. + /// + /// A count that does not fit reports `SQL_NO_TOTAL` (-1), the spec's "the + /// driver cannot determine the row count" — which is what a value this + /// type cannot name actually means. It is unreachable in practice: rows + /// are materialised in memory, so `i64::MAX` of them cannot be held. + fn row_count(&self) -> Option<i64> { + const SQL_NO_TOTAL: i64 = -1; + + let count = self.affected_rows.unwrap_or(self.rows.len()); + Some(i64::try_from(count).unwrap_or(SQL_NO_TOTAL)) } - fn close_cursor(&mut self) { + /// Fallible in the trait because for a networked data source closing a + /// cursor is a round trip. Here the rows are already materialised in + /// memory, so resetting the cursor index cannot fail. + fn close_cursor(&mut self) -> Result<(), SqliteError> { self.cursor = -1; + Ok(()) } } @@ -372,10 +427,14 @@ mod tests { fn get_data_before_fetch_is_no_result_set() { let conn = conn_with("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);"); let mut stmt = exec_direct(&conn, "SELECT id FROM t").unwrap(); - // No fetch() yet: the cursor is before the first row. + // No fetch() yet: the cursor is before the first row. The statement + // reports the backend's own error type now, and `SqliteError::Odbc` + // is what carries core's `NoResultSet` through it unchanged. assert!(matches!( stmt.get_data(1, CDataType::SLong), - Err(OdbcError::NoResultSet) + Err(SqliteError::Odbc { + source: OdbcError::NoResultSet + }) )); } diff --git a/src/backend/info.rs b/src/backend/info.rs index 0d608bd..0893667 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -5,10 +5,9 @@ //! bitmaps (`SQLITE_*`). use stackable_odbc_core::backend::{Backend, common_get_info_raw, default_get_info}; -use stackable_odbc_core::errors::OdbcError; -use stackable_odbc_core::function_id::FunctionId; +use stackable_odbc_core::function_id::{CORE_EXPORTED_FUNCTIONS, FunctionId}; use stackable_odbc_core::types::{ - InfoType, InfoValue, MaxPrecision, MaxScale, Nullable, SQL_AF_ALL, SQL_AF_AVG, SQL_AF_COUNT, + InfoType, InfoValue, MaxPrecision, MaxScale, SQL_AF_ALL, SQL_AF_AVG, SQL_AF_COUNT, SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, SQL_AT_ADD_COLUMN_COLLATION, SQL_AT_ADD_COLUMN_DEFAULT, SQL_AT_ADD_COLUMN_SINGLE, SQL_AT_ADD_CONSTRAINT, SQL_AT_ADD_TABLE_CONSTRAINT, SQL_AT_CONSTRAINT_NAME_DEFINITION, @@ -18,9 +17,8 @@ use stackable_odbc_core::types::{ SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, SQL_FN_SYS_IFNULL, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_NOW, - SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_LIKE_ESCAPE_CLAUSE, - SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, - SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, SQL_SEARCHABLE, + SQL_LIKE_ESCAPE_CLAUSE, SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, SQL_OJ_FULL, + SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, SQL_OUTER_JOINS, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, @@ -44,6 +42,11 @@ use crate::type_conversion::{ /// ODBC function IDs for functions this driver implements. /// Used by `SQLGetFunctions` to report supported capabilities. /// Reference: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetfunctions-function> +/// +/// Superseded by [`CORE_EXPORTED_FUNCTIONS`], which [`get_functions`] returns +/// instead; kept only so `supported_functions_are_all_exported_by_core` can +/// assert the two agree. See that test for why the hand-written list went. +#[cfg(test)] static SUPPORTED_FUNCTIONS: &[FunctionId] = &[ FunctionId::BindCol, FunctionId::ColAttribute, @@ -126,275 +129,123 @@ static SQLITE_TYPE_INFO: &[TypeInfoRow] = &[ // actually satisfies the invariant for every text-affinity declared // type; the SQL_VARCHAR/SQL_CHAR rows further down this list // exist only for Windows DM/pyodbc ANSI compatibility. - TypeInfoRow { - type_name: "WVARCHAR", - data_type: SqlDataType::EXT_W_VARCHAR, - column_size: catalog_column_size( + TypeInfoRow::new("WVARCHAR", SqlDataType::EXT_W_VARCHAR) + .with_column_size(catalog_column_size( SqlDataType::EXT_W_VARCHAR, MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), MaxScale(0), - ), - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: Some("max length"), - nullable: Nullable::SqlNullable as i16, - case_sensitive: true, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::EXT_W_VARCHAR.0, - sql_datetime_sub: None, - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), // WCHAR — Unicode counterpart to the CHAR row further down this list, // included for symmetry per the Windows DM checklist even though // sqlite_type_to_sql_data_type itself never produces EXT_W_CHAR (declared // CHAR(n) collapses into the WVARCHAR affinity above, matching real // SQLite semantics where CHAR(n) is not length-limited). - TypeInfoRow { - type_name: "WCHAR", - data_type: SqlDataType::EXT_W_CHAR, - column_size: catalog_column_size( + TypeInfoRow::new("WCHAR", SqlDataType::EXT_W_CHAR) + .with_column_size(catalog_column_size( SqlDataType::EXT_W_CHAR, MaxPrecision(WCHAR_COLUMN_SIZE_ROW), MaxScale(0), - ), - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: Some("length"), - nullable: Nullable::SqlNullable as i16, - case_sensitive: true, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::EXT_W_CHAR.0, - sql_datetime_sub: None, - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("length")) + .with_case_sensitive(true), // BIT — sqlite_type_to_sql_data_type maps BOOLEAN/BOOL here. - TypeInfoRow { - type_name: "BIT", - data_type: SqlDataType::EXT_BIT, - column_size: catalog_column_size(SqlDataType::EXT_BIT, MaxPrecision(0), MaxScale(0)), - literal_prefix: None, - literal_suffix: None, - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::EXT_BIT.0, - sql_datetime_sub: None, - num_prec_radix: None, - interval_precision: None, - }, + TypeInfoRow::new("BIT", SqlDataType::EXT_BIT).with_column_size(catalog_column_size( + SqlDataType::EXT_BIT, + MaxPrecision(0), + MaxScale(0), + )), // TINYINT — sqlite_type_to_sql_data_type maps TINYINT here. - TypeInfoRow { - type_name: "TINYINT", - data_type: SqlDataType::EXT_TINY_INT, - column_size: catalog_column_size(SqlDataType::EXT_TINY_INT, MaxPrecision(0), MaxScale(0)), - literal_prefix: None, - literal_suffix: None, - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: Some(false), - fixed_prec_scale: false, - auto_unique_value: Some(false), - local_type_name: None, - minimum_scale: Some(0), - maximum_scale: Some(0), - sql_data_type: SqlDataType::EXT_TINY_INT.0, - sql_datetime_sub: None, - num_prec_radix: Some(10), - interval_precision: None, - }, + TypeInfoRow::new("TINYINT", SqlDataType::EXT_TINY_INT) + .with_column_size(catalog_column_size( + SqlDataType::EXT_TINY_INT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), // BIGINT — sqlite_type_to_sql_data_type maps INTEGER/INT/BIGINT/INT8 here // (and the "INT"-substring affinity fallback), since SQLite integers are // always 64-bit storage. This is the row an INTEGER column's reported // type (SQL_BIGINT) actually resolves to. - TypeInfoRow { - type_name: "BIGINT", - data_type: SqlDataType::EXT_BIG_INT, - column_size: catalog_column_size(SqlDataType::EXT_BIG_INT, MaxPrecision(0), MaxScale(0)), - literal_prefix: None, - literal_suffix: None, - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: Some(false), - fixed_prec_scale: false, - auto_unique_value: Some(false), - local_type_name: None, - minimum_scale: Some(0), - maximum_scale: Some(0), - sql_data_type: SqlDataType::EXT_BIG_INT.0, - sql_datetime_sub: None, - num_prec_radix: Some(10), - interval_precision: None, - }, - TypeInfoRow { - type_name: "BLOB", - data_type: SqlDataType::EXT_VAR_BINARY, - column_size: catalog_column_size( + TypeInfoRow::new("BIGINT", SqlDataType::EXT_BIG_INT) + .with_column_size(catalog_column_size( + SqlDataType::EXT_BIG_INT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new("BLOB", SqlDataType::EXT_VAR_BINARY) + .with_column_size(catalog_column_size( SqlDataType::EXT_VAR_BINARY, MaxPrecision(BLOB_DEFAULT_COLUMN_SIZE), MaxScale(0), - ), - literal_prefix: Some("X'"), - literal_suffix: Some("'"), - create_params: Some("max length"), - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::EXT_VAR_BINARY.0, - sql_datetime_sub: None, - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("X'"), Some("'")) + .with_create_params(Some("max length")), // SQL_CHAR (1) — ANSI alias. See the SQL_VARCHAR comment further down // this list; same rationale for why this is a distinct row from the // WCHAR row above. - TypeInfoRow { - type_name: "CHAR", - data_type: SqlDataType::CHAR, - column_size: catalog_column_size( + TypeInfoRow::new("CHAR", SqlDataType::CHAR) + .with_column_size(catalog_column_size( SqlDataType::CHAR, MaxPrecision(CHAR_COLUMN_SIZE_ROW), MaxScale(0), - ), - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: Some("length"), - nullable: Nullable::SqlNullable as i16, - case_sensitive: true, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::CHAR.0, - sql_datetime_sub: None, - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("length")) + .with_case_sensitive(true), // DECIMAL — sqlite_type_to_sql_data_type maps DECIMAL/NUMERIC here, and // it is also the NUMERIC-affinity fallback for any declared type that // SQLite's own affinity rules do not otherwise classify. - TypeInfoRow { - type_name: "DECIMAL", - data_type: SqlDataType::DECIMAL, - column_size: catalog_column_size( + TypeInfoRow::new("DECIMAL", SqlDataType::DECIMAL) + .with_column_size(catalog_column_size( SqlDataType::DECIMAL, MaxPrecision(DECIMAL_DEFAULT_COLUMN_SIZE), MaxScale(DECIMAL_MAX_SCALE), - ), - literal_prefix: None, - literal_suffix: None, - create_params: Some("precision,scale"), - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: Some(false), - fixed_prec_scale: false, - auto_unique_value: Some(false), - local_type_name: None, - minimum_scale: Some(0), - maximum_scale: Some(DECIMAL_MAX_SCALE), - sql_data_type: SqlDataType::DECIMAL.0, - sql_datetime_sub: None, - num_prec_radix: Some(10), - interval_precision: None, - }, - TypeInfoRow { - type_name: "INTEGER", - data_type: SqlDataType::INTEGER, - column_size: catalog_column_size(SqlDataType::INTEGER, MaxPrecision(0), MaxScale(0)), - literal_prefix: None, - literal_suffix: None, - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: Some(false), - fixed_prec_scale: false, - auto_unique_value: Some(false), - local_type_name: None, - minimum_scale: Some(0), - maximum_scale: Some(0), - sql_data_type: SqlDataType::INTEGER.0, - sql_datetime_sub: None, - num_prec_radix: Some(10), - interval_precision: None, - }, + )) + .with_create_params(Some("precision,scale")) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(DECIMAL_MAX_SCALE)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new("INTEGER", SqlDataType::INTEGER) + .with_column_size(catalog_column_size( + SqlDataType::INTEGER, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), // SMALLINT — sqlite_type_to_sql_data_type maps SMALLINT/INT2 here. - TypeInfoRow { - type_name: "SMALLINT", - data_type: SqlDataType::SMALLINT, - column_size: catalog_column_size(SqlDataType::SMALLINT, MaxPrecision(0), MaxScale(0)), - literal_prefix: None, - literal_suffix: None, - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: Some(false), - fixed_prec_scale: false, - auto_unique_value: Some(false), - local_type_name: None, - minimum_scale: Some(0), - maximum_scale: Some(0), - sql_data_type: SqlDataType::SMALLINT.0, - sql_datetime_sub: None, - num_prec_radix: Some(10), - interval_precision: None, - }, - TypeInfoRow { - type_name: "REAL", - data_type: SqlDataType::DOUBLE, - column_size: catalog_column_size(SqlDataType::DOUBLE, MaxPrecision(0), MaxScale(0)), - literal_prefix: None, - literal_suffix: None, - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: Some(false), - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::DOUBLE.0, - sql_datetime_sub: None, - num_prec_radix: Some(2), - interval_precision: None, - }, + TypeInfoRow::new("SMALLINT", SqlDataType::SMALLINT) + .with_column_size(catalog_column_size( + SqlDataType::SMALLINT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new("REAL", SqlDataType::DOUBLE) + .with_column_size(catalog_column_size( + SqlDataType::DOUBLE, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_num_prec_radix(Some(2)), // TEXT — column_size matches VARCHAR_DEFAULT_COLUMN_SIZE (255), the // same default `default_precision_for_type` reports for both VARCHAR and // EXT_W_VARCHAR (see type_conversion.rs). This row and the VARCHAR row @@ -403,31 +254,15 @@ static SQLITE_TYPE_INFO: &[TypeInfoRow] = &[ // the same size. 255 is the value the rest of the driver treats as // authoritative for this DATA_TYPE (`default_precision_for_type`, and the // WVARCHAR row below), so both rows use it. - TypeInfoRow { - type_name: "TEXT", - data_type: SqlDataType::VARCHAR, - column_size: catalog_column_size( + TypeInfoRow::new("TEXT", SqlDataType::VARCHAR) + .with_column_size(catalog_column_size( SqlDataType::VARCHAR, MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), MaxScale(0), - ), - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: Some("max length"), - nullable: Nullable::SqlNullable as i16, - case_sensitive: true, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::VARCHAR.0, - sql_datetime_sub: None, - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), // SQL_VARCHAR (12) — ANSI alias needed for Windows DM / pyodbc type // conversion (AGENTS.md "Windows Driver Manager compatibility // checklist"). sqlite_type_to_sql_data_type never actually returns this @@ -438,89 +273,45 @@ static SQLITE_TYPE_INFO: &[TypeInfoRow] = &[ // spec explicitly allows multiple rows sharing a DATA_TYPE; column_size // matches the TEXT row above for the same reason (see that row's // comment). - TypeInfoRow { - type_name: "VARCHAR", - data_type: SqlDataType::VARCHAR, - column_size: catalog_column_size( + TypeInfoRow::new("VARCHAR", SqlDataType::VARCHAR) + .with_column_size(catalog_column_size( SqlDataType::VARCHAR, MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), MaxScale(0), - ), - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: Some("max length"), - nullable: Nullable::SqlNullable as i16, - case_sensitive: true, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::VARCHAR.0, - sql_datetime_sub: None, - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), // DATE — sqlite_type_to_sql_data_type maps DATE here. SQLite has no DATE // literal syntax; a date value is just a quoted ISO-8601 string, hence // the plain quote prefix/suffix (matching the TEXT row's convention) // rather than a typed `DATE '...'` literal. // DATA_TYPE=91 (SQL_TYPE_DATE), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=1 (SQL_CODE_DATE) - TypeInfoRow { - type_name: "DATE", - data_type: SqlDataType::DATE, - column_size: catalog_column_size(SqlDataType::DATE, MaxPrecision(0), MaxScale(0)), // 'YYYY-MM-DD' - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: None, - maximum_scale: None, - sql_data_type: SqlDataType::DATETIME.0, - sql_datetime_sub: Some(SQL_CODE_DATE), - num_prec_radix: None, - interval_precision: None, - }, + TypeInfoRow::new("DATE", SqlDataType::DATE) + .with_column_size(catalog_column_size( + SqlDataType::DATE, + MaxPrecision(0), + MaxScale(0), + )) + // 'YYYY-MM-DD' + .with_literal_affixes(Some("'"), Some("'")) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_DATE)), // TIME — sqlite_type_to_sql_data_type maps TIME here. SQLite stores time // values as plain "HH:MM:SS" text with no fractional-seconds field (see // column_value_to_rusqlite), so scale is fixed at 0. // DATA_TYPE=92 (SQL_TYPE_TIME), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=2 (SQL_CODE_TIME) - TypeInfoRow { - type_name: "TIME", - data_type: SqlDataType::TIME, + TypeInfoRow::new("TIME", SqlDataType::TIME) // 'HH:MM:SS': SQLite has no fractional-seconds capability to report // as a maximum (MAX_FRACTIONAL_SECONDS_PRECISION = 0), so this is // the plain (scale-0) form of the TIME formula. - column_size: catalog_column_size( + .with_column_size(catalog_column_size( SqlDataType::TIME, MaxPrecision(0), MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), - ), - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: Some(0), - maximum_scale: Some(MAX_FRACTIONAL_SECONDS_PRECISION), - sql_data_type: SqlDataType::DATETIME.0, - sql_datetime_sub: Some(SQL_CODE_TIME), - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIME)), // TIMESTAMP — sqlite_type_to_sql_data_type maps DATETIME/TIMESTAMP here. // column_size intentionally excludes a fractional-seconds allowance: it // is computed via catalog_column_size at MAX_FRACTIONAL_SECONDS_PRECISION @@ -529,33 +320,17 @@ static SQLITE_TYPE_INFO: &[TypeInfoRow] = &[ // below), so minimum/maximum scale are reported as fixed at 0 rather // than claiming precision the column size does not budget for. // DATA_TYPE=93 (SQL_TYPE_TIMESTAMP), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=3 (SQL_CODE_TIMESTAMP) - TypeInfoRow { - type_name: "TIMESTAMP", - data_type: SqlDataType::TIMESTAMP, + TypeInfoRow::new("TIMESTAMP", SqlDataType::TIMESTAMP) // 'YYYY-MM-DD HH:MM:SS': same no-fractional-capability rationale // as the TIME row above. - column_size: catalog_column_size( + .with_column_size(catalog_column_size( SqlDataType::TIMESTAMP, MaxPrecision(0), MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), - ), - literal_prefix: Some("'"), - literal_suffix: Some("'"), - create_params: None, - nullable: Nullable::SqlNullable as i16, - case_sensitive: false, - searchable: SQL_SEARCHABLE, - unsigned: None, - fixed_prec_scale: false, - auto_unique_value: None, - local_type_name: None, - minimum_scale: Some(0), - maximum_scale: Some(MAX_FRACTIONAL_SECONDS_PRECISION), - sql_data_type: SqlDataType::DATETIME.0, - sql_datetime_sub: Some(SQL_CODE_TIMESTAMP), - num_prec_radix: None, - interval_precision: None, - }, + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIMESTAMP)), ]; // `CHAR`/`WCHAR`'s "unbounded" sentinel. `VARCHAR`/`DECIMAL`/`BLOB`'s default @@ -599,7 +374,6 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { } })); } - InfoType::IdentifierCase => return Ok(InfoValue::U16(SQL_IC_MIXED)), // 0, not an identifier length: this driver reports no catalogs and no // schemas, so there is no name whose maximum length these could // describe. Core defaults them to its generic identifier length, which @@ -658,31 +432,23 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { // (`stackable_odbc_core::conformance`). `SQL_TC_DML` is a small fixed constant // (1), so the narrowing `as u16` cannot lose information. InfoType::TransactionCapable => return Ok(InfoValue::U16(SQL_TC_DML as u16)), - // SQL_GD_BLOCK is deliberately not claimed: it means SQLGetData can - // be called for a row in a block cursor after a bulk fetch, but this - // driver has no block cursors to speak of -- `SQLSetStmtAttrW` - // (`stackable-odbc-core/src/ffi/stmt_attr.rs`) rejects any - // SQL_ATTR_ROW_ARRAY_SIZE other than 1, substituting 1 back with - // 01S02, so no application can ever get a multi-row rowset out of - // this driver to begin with. SQL_GD_BOUND, by contrast, genuinely - // holds: `sql_get_data` (`stackable-odbc-core/src/ffi/fetch.rs`) never checks - // `stmt.bindings` before reading a column, so a column bound via - // `SQLBindCol` can still be fetched again through `SQLGetData`. - // Reporting the exact capability set (rather than a blanket 0x0F) is - // what the Windows DM checklist in AGENTS.md requires. - InfoType::GetDataExtensions => { - return Ok(InfoValue::U32( - SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND, - )); - } + // SQL_GETDATA_EXTENSIONS is deliberately not answered here. It states + // what core's own fetch path supports -- `sql_get_data` checks neither + // column order nor binding state, and `sql_set_stmt_attr_w` substitutes + // 1 back for any SQL_ATTR_ROW_ARRAY_SIZE, so no block cursor can exist + // for SQL_GD_BLOCK to describe. None of that is a fact about SQLite, + // and this driver cannot keep it true if core's fetch path changes. + // Core answers it, and `get_info_snapshot` below still pins the value + // an application actually sees. _ => {} } - // Fall through to shared defaults - default_get_info::<SqliteBackend>(info_type, &SqliteBackend::catalog_result_column_widths()) - .ok_or_else(|| SqliteError::NotImplemented { - feature: format!("get_info({info_type:?})"), - }) + // Fall through to shared defaults. Core reads the catalog result column + // widths off the backend type parameter itself, so they cannot disagree + // with what this driver reports everywhere else. + default_get_info::<SqliteBackend>(info_type).ok_or_else(|| SqliteError::NotImplemented { + feature: format!("get_info({info_type:?})"), + }) } pub(super) fn get_info( @@ -761,8 +527,8 @@ fn connection_limit( })) } -pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, OdbcError> { - sqlite_get_info(info_type).map_err(Into::into) +pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, SqliteError> { + sqlite_get_info(info_type) } /// `SQL_AGGREGATE_FUNCTIONS` — SQLite has every ODBC aggregate, and accepts @@ -1083,8 +849,18 @@ pub(super) fn get_info_raw( } } +/// Every ODBC function this driver supports — which is exactly the set +/// `forward_ffi!` generates a C entry point for. +/// +/// Derived from core rather than hand-listed. `SQLGetFunctions` is what the +/// Windows Driver Manager builds its dispatch table from, so a name in here +/// that core does not export hands the DM a null pointer to call; core pins +/// the list against its own macro arms, which no list maintained here could +/// do. The previous hand-written list over-claimed nothing but had drifted to +/// 53 of the 69 exported entry points, under-reporting sixteen the driver does +/// in fact export. pub(super) fn get_functions() -> &'static [FunctionId] { - SUPPORTED_FUNCTIONS + CORE_EXPORTED_FUNCTIONS } pub(super) fn get_type_info() -> &'static [TypeInfoRow] { @@ -2533,6 +2309,28 @@ mod tests { assert!(f.contains(&FunctionId::PutData), "SQLPutData missing"); } + /// Nothing this driver ever claimed to support is absent from what core + /// exports. + /// + /// The check that matters is this direction. `SQLGetFunctions` is what the + /// Windows Driver Manager builds its dispatch table from, so claiming a + /// function core does not export hands it a null pointer to call — whereas + /// staying silent about one merely means the DM does not use it. + /// + /// `SUPPORTED_FUNCTIONS` is the hand-written list `get_functions` used to + /// return. It is kept as the historical claim so this assertion has + /// something to check; the live answer is `CORE_EXPORTED_FUNCTIONS`, which + /// core pins against its own `forward_ffi!` arms. + #[test] + fn supported_functions_are_all_exported_by_core() { + for id in SUPPORTED_FUNCTIONS { + assert!( + CORE_EXPORTED_FUNCTIONS.contains(id), + "{id:?} was advertised but core exports no entry point for it" + ); + } + } + #[test] fn get_functions_has_no_duplicates() { let f = get_functions(); diff --git a/src/backend/metadata.rs b/src/backend/metadata.rs index 6509e9d..231cbfe 100644 --- a/src/backend/metadata.rs +++ b/src/backend/metadata.rs @@ -4,7 +4,6 @@ //! query helpers those functions share. use stackable_odbc_core::backend::Backend; -use stackable_odbc_core::errors::OdbcError; use stackable_odbc_core::types::{ ColumnDescriptor, ColumnValue, ColumnsResultCol, ForeignKeysResultCol, IdentifierType, Nullable, PrimaryKeysResultCol, SQL_CASCADE, SQL_INDEX_OTHER, SQL_NO_ACTION, SQL_PC_NOT_PSEUDO, @@ -423,17 +422,13 @@ pub(super) fn primary_keys( _catalog: Option<&str>, _schema: Option<&str>, table: Option<&str>, -) -> Result<SqliteStatement, OdbcError> { - let db = conn.conn.lock().map_err(|e| { - OdbcError::general( - format!("Mutex poisoned: {e}"), - stackable_odbc_core::types::SqlState::general_error(), - ) +) -> Result<SqliteStatement, SqliteError> { + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), })?; // Collect table names to query (either the specific one or all tables). - let table_names = - tables_to_inspect(&db, table).map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let table_names = tables_to_inspect(&db, table).map_err(map_sqlite_error)?; let mut result_rows: Vec<Vec<ColumnValue>> = Vec::new(); for table_name in &table_names { @@ -442,24 +437,21 @@ pub(super) fn primary_keys( // needs no manual escaping. Same columns, same order as the PRAGMA. let mut pragma_stmt = db .prepare("SELECT * FROM pragma_table_info(?1)") - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let mut pragma_rows = pragma_stmt .query(rusqlite::params![table_name]) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; // Collect pk columns: (key_seq, col_name) let mut pk_cols: Vec<(i64, String)> = Vec::new(); - while let Some(row) = pragma_rows - .next() - .map_err(|e| OdbcError::from(map_sqlite_error(e)))? - { + while let Some(row) = pragma_rows.next().map_err(map_sqlite_error)? { let pk_seq: i64 = row .get(pragma_table_info_col::PK) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; if pk_seq > 0 { let col_name: String = row .get(pragma_table_info_col::NAME) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; pk_cols.push((pk_seq, col_name)); } } @@ -502,51 +494,33 @@ pub(super) fn foreign_keys( _fk_catalog: Option<&str>, _fk_schema: Option<&str>, fk_table: Option<&str>, -) -> Result<SqliteStatement, OdbcError> { - let db = conn.conn.lock().map_err(|e| { - OdbcError::general( - format!("Mutex poisoned: {e}"), - stackable_odbc_core::types::SqlState::general_error(), - ) +) -> Result<SqliteStatement, SqliteError> { + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), })?; // Which FK tables do we query? - let fk_table_names = - tables_to_inspect(&db, fk_table).map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let fk_table_names = tables_to_inspect(&db, fk_table).map_err(map_sqlite_error)?; let mut result_rows: Vec<Vec<ColumnValue>> = Vec::new(); for fk_tbl in &fk_table_names { let pragma_sql = format!("PRAGMA foreign_key_list('{}')", fk_tbl.replace('\'', "''")); - let mut pragma_stmt = db - .prepare(&pragma_sql) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; - let mut pragma_rows = pragma_stmt - .query([]) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + let mut pragma_stmt = db.prepare(&pragma_sql).map_err(map_sqlite_error)?; + let mut pragma_rows = pragma_stmt.query([]).map_err(map_sqlite_error)?; - while let Some(row) = pragma_rows - .next() - .map_err(|e| OdbcError::from(map_sqlite_error(e)))? - { - let seq: i64 = row - .get(pragma_fk_col::SEQ) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; - let referenced_table: String = row - .get(pragma_fk_col::TABLE) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; - let from_col: String = row - .get(pragma_fk_col::FROM) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; - let to_col: Option<String> = row - .get(pragma_fk_col::TO) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + while let Some(row) = pragma_rows.next().map_err(map_sqlite_error)? { + let seq: i64 = row.get(pragma_fk_col::SEQ).map_err(map_sqlite_error)?; + let referenced_table: String = + row.get(pragma_fk_col::TABLE).map_err(map_sqlite_error)?; + let from_col: String = row.get(pragma_fk_col::FROM).map_err(map_sqlite_error)?; + let to_col: Option<String> = row.get(pragma_fk_col::TO).map_err(map_sqlite_error)?; let on_update: String = row .get(pragma_fk_col::ON_UPDATE) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let on_delete: String = row .get(pragma_fk_col::ON_DELETE) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; // Filter by pk_table if specified. if let Some(pkt) = pk_table @@ -605,7 +579,7 @@ pub(super) fn statistics( _schema: Option<&str>, table: Option<&str>, unique_only: bool, -) -> Result<SqliteStatement, OdbcError> { +) -> Result<SqliteStatement, SqliteError> { use stackable_odbc_core::types::{SQL_FALSE, SQL_TRUE}; let widths = SqliteBackend::catalog_result_column_widths(); @@ -617,11 +591,8 @@ pub(super) fn statistics( return Ok(SqliteStatement::new(columns, Vec::new())); }; - let db = conn.conn.lock().map_err(|e| { - OdbcError::general( - format!("Mutex poisoned: {e}"), - stackable_odbc_core::types::SqlState::general_error(), - ) + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), })?; // CARDINALITY for the table-stat row: read sqlite_stat1 only if present. @@ -648,26 +619,23 @@ pub(super) fn statistics( // Enumerate indexes. Use the pragma_ TVF form so the name binds safely. let mut list_stmt = db .prepare("SELECT * FROM pragma_index_list(?1)") - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let mut list_rows = list_stmt .query(rusqlite::params![table]) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; // (index_name, is_unique, is_partial) let mut indexes: Vec<(String, bool, bool)> = Vec::new(); - while let Some(r) = list_rows - .next() - .map_err(|e| OdbcError::from(map_sqlite_error(e)))? - { + while let Some(r) = list_rows.next().map_err(map_sqlite_error)? { let name: String = r .get(pragma_index_list_col::NAME) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let unique: i64 = r .get(pragma_index_list_col::UNIQUE) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let partial: i64 = r .get(pragma_index_list_col::PARTIAL) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let is_unique = unique != 0; if unique_only && !is_unique { continue; @@ -680,19 +648,16 @@ pub(super) fn statistics( for (index_name, is_unique, is_partial) in &indexes { let mut xinfo_stmt = db .prepare("SELECT * FROM pragma_index_xinfo(?1)") - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let mut xinfo_rows = xinfo_stmt .query(rusqlite::params![index_name]) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let mut ordinal: i16 = 0; - while let Some(r) = xinfo_rows - .next() - .map_err(|e| OdbcError::from(map_sqlite_error(e)))? - { + while let Some(r) = xinfo_rows.next().map_err(map_sqlite_error)? { let key: i64 = r .get(pragma_index_xinfo_col::KEY) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; if key == 0 { continue; // auxiliary column (e.g. trailing rowid), not part of the key } @@ -700,10 +665,10 @@ pub(super) fn statistics( // COLUMN_NAME is NULL for an expression index; spec wants "" then. let col_name: Option<String> = r .get(pragma_index_xinfo_col::NAME) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let desc: i64 = r .get(pragma_index_xinfo_col::DESC) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; rows.push(vec![ ColumnValue::Null, // TABLE_CAT @@ -797,7 +762,7 @@ pub(super) fn special_columns( table: Option<&str>, scope: Scope, _nullable: Nullable, // our identifiers are all NOT NULL -> Nullable never filters -) -> Result<SqliteStatement, OdbcError> { +) -> Result<SqliteStatement, SqliteError> { let widths = SqliteBackend::catalog_result_column_widths(); let columns = special_columns_columns(&widths); let empty = || Ok(SqliteStatement::new(columns.clone(), Vec::new())); @@ -810,20 +775,17 @@ pub(super) fn special_columns( return empty(); }; - let db = conn.conn.lock().map_err(|e| { - OdbcError::general( - format!("Mutex poisoned: {e}"), - stackable_odbc_core::types::SqlState::general_error(), - ) + let db = conn.conn.lock().map_err(|e| SqliteError::General { + message: format!("Mutex poisoned: {e}"), })?; // Gather (name, decl_type, pk_seq) for every column via the pragma TVF. let mut info_stmt = db .prepare("SELECT * FROM pragma_table_info(?1)") - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let mut info_rows = info_stmt .query(rusqlite::params![table]) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; struct Col { name: String, @@ -831,19 +793,14 @@ pub(super) fn special_columns( pk: i64, } let mut cols: Vec<Col> = Vec::new(); - while let Some(r) = info_rows - .next() - .map_err(|e| OdbcError::from(map_sqlite_error(e)))? - { + while let Some(r) = info_rows.next().map_err(map_sqlite_error)? { let name: String = r .get(pragma_table_info_col::NAME) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; let decl_type: Option<String> = r .get(pragma_table_info_col::TYPE) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; - let pk: i64 = r - .get(pragma_table_info_col::PK) - .map_err(|e| OdbcError::from(map_sqlite_error(e)))?; + .map_err(map_sqlite_error)?; + let pk: i64 = r.get(pragma_table_info_col::PK).map_err(map_sqlite_error)?; cols.push(Col { name, decl_type: decl_type.unwrap_or_default(), @@ -970,7 +927,7 @@ fn special_column_row_bigint(name: &str, pseudo: i16, scope: Scope) -> Vec<Colum /// "no such column" message is treated as "not a rowid table"; any other /// error (a genuine failure, not the WITHOUT ROWID case) is routed through /// `map_sqlite_error`. -fn table_is_rowid(db: &rusqlite::Connection, table: &str) -> Result<bool, OdbcError> { +fn table_is_rowid(db: &rusqlite::Connection, table: &str) -> Result<bool, SqliteError> { // Identifier cannot be bound; quote it, doubling embedded quotes. let quoted = format!("\"{}\"", table.replace('"', "\"\"")); match db.prepare(&format!("SELECT rowid FROM {quoted} LIMIT 0")) { @@ -981,7 +938,7 @@ fn table_is_rowid(db: &rusqlite::Connection, table: &str) -> Result<bool, OdbcEr Ok(false) } // Any other error shape is a genuine failure. - Err(e) => Err(OdbcError::from(map_sqlite_error(e))), + Err(e) => Err(map_sqlite_error(e)), } } diff --git a/src/escape_dialect.rs b/src/escape_dialect.rs index f56fb03..9eb0ef9 100644 --- a/src/escape_dialect.rs +++ b/src/escape_dialect.rs @@ -107,14 +107,11 @@ fn render_bare(x: &str) -> String { /// SQLite's `EscapeDialect`: all three SQLite identifier-quoting styles /// (`"`, `` ` ``, `[...]`) and bare-string date/time/timestamp literals. pub(crate) fn dialect() -> EscapeDialect { - EscapeDialect { - identifier_quotes: &[('"', '"'), ('`', '`'), ('[', ']')], - remap_scalar_fn, - rewrite_scalar_fn, - render_date: render_bare, - render_time: render_bare, - render_timestamp: render_bare, - } + EscapeDialect::ansi_default() + .with_identifier_quotes(&[('"', '"'), ('`', '`'), ('[', ']')]) + .with_remap_scalar_fn(remap_scalar_fn) + .with_rewrite_scalar_fn(rewrite_scalar_fn) + .with_datetime_renderers(render_bare, render_bare, render_bare) } #[cfg(test)] diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 0c5d432..86e7156 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -12,11 +12,12 @@ use stackable_odbc_core::{ ffi, types::{ AttrOdbcVersion, CDataType, CompletionType, ConnectionAttribute, Desc, - EnvironmentAttribute, HandleType, HeaderDiagnosticIdentifier, InfoType, Numeric, ParamType, - SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, SQL_CASCADE, SQL_CD_FALSE, SQL_CURSOR_FORWARD_ONLY, - SQL_DIAG_MESSAGE_TEXT, SQL_DRIVER_ODBC_VER_STRING, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, - SQL_GD_BOUND, SQL_IC_SENSITIVE, SQL_INDEX_UNIQUE, SQL_QUICK, SQL_RESTRICT, SqlDataType, - SqlReturn, StatementAttribute, Timestamp, expected_kind, + EnvironmentAttribute, HandleType, HeaderDiagnosticIdentifier, InfoType, Nullable, Numeric, + ParamType, SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, SQL_CASCADE, SQL_CD_FALSE, + SQL_CURSOR_FORWARD_ONLY, SQL_DIAG_MESSAGE_TEXT, SQL_DRIVER_ODBC_VER_STRING, + SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_SENSITIVE, SQL_INDEX_UNIQUE, + SQL_QUICK, SQL_RESTRICT, SqlDataType, SqlReturn, StatementAttribute, Timestamp, + expected_kind, }, }; @@ -77,6 +78,96 @@ unsafe fn exec_direct(stmt: *mut c_void, sql: &str) -> SqlReturn { } } +/// Helper: run setup SQL through the driver's own `SQLExecDirect`. +/// +/// These tests used to reach into `ConnectionHandle` for the underlying +/// `rusqlite::Connection` and call `execute_batch` on it. Core's `handles` +/// module is `pub(crate)` now, so that route is gone — and driving setup +/// through the same entry points under test is the better answer anyway: a +/// setup that silently stopped working fails here instead of leaving the test +/// asserting against an empty table. +/// +/// `SQLExecDirect` executes one statement, so a multi-statement setup is split +/// on `;`. Every setup in this file is DDL and `INSERT`s with no `;` inside a +/// string literal, which is what makes a plain split exact here. +/// Both this and [`query_scalar_i64`] allocate their own statement handle +/// rather than borrowing the caller's. The statement the test is asserting +/// about usually holds live state — a cursor, a prepared statement, bound +/// parameters — and running setup or a read-back over it would destroy exactly +/// what the test is there to check. +unsafe fn setup_sql(conn: *mut c_void, sql: &str) { + unsafe { + let stmt = alloc_stmt(conn); + for one in sql.split(';').map(str::trim).filter(|s| !s.is_empty()) { + assert_eq!( + exec_direct(stmt, one), + SqlReturn::SUCCESS, + "setup statement failed: {one}" + ); + } + let _ = ffi::handle::sql_free_handle::<SqliteBackend>(HandleType::Stmt as i16, stmt); + } +} + +/// Helper: allocate a statement handle on `conn`. +unsafe fn alloc_stmt(conn: *mut c_void) -> *mut c_void { + let mut stmt: *mut c_void = std::ptr::null_mut(); + let ret = unsafe { + ffi::handle::sql_alloc_handle::<SqliteBackend>(HandleType::Stmt as i16, conn, &mut stmt) + }; + assert_eq!(ret, SqlReturn::SUCCESS, "could not allocate a statement"); + stmt +} + +/// Helper: read a single-row, single-column `i64` back through the FFI. +/// +/// The read-back replacement for the `db.query_row(..)` calls that used to +/// reach into the connection handle. +unsafe fn query_scalar_i64(conn: *mut c_void, sql: &str) -> i64 { + unsafe { + let stmt = alloc_stmt(conn); + assert_eq!(exec_direct(stmt, sql), SqlReturn::SUCCESS, "query: {sql}"); + assert_eq!( + ffi::fetch::sql_fetch::<SqliteBackend>(stmt), + SqlReturn::SUCCESS, + "query returned no row: {sql}" + ); + let mut val: i64 = 0; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<SqliteBackend>( + stmt, + 1, + CDataType::SBigInt as i16, + &raw mut val as *mut c_void, + std::mem::size_of::<i64>() as isize, + &mut ind, + ), + SqlReturn::SUCCESS, + "get_data: {sql}" + ); + let _ = ffi::handle::sql_free_handle::<SqliteBackend>(HandleType::Stmt as i16, stmt); + val + } +} + +/// Helper: read a single row of two string columns back through the FFI. +unsafe fn query_row_two_strings(conn: *mut c_void, sql: &str) -> (String, String) { + unsafe { + let stmt = alloc_stmt(conn); + assert_eq!(exec_direct(stmt, sql), SqlReturn::SUCCESS, "query: {sql}"); + assert_eq!( + ffi::fetch::sql_fetch::<SqliteBackend>(stmt), + SqlReturn::SUCCESS, + "query returned no row: {sql}" + ); + let first = fetch_string_col(stmt, 1); + let second = fetch_string_col(stmt, 2); + let _ = ffi::handle::sql_free_handle::<SqliteBackend>(HandleType::Stmt as i16, stmt); + (first, second) + } +} + /// Helper: free all handles. unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { unsafe { @@ -95,20 +186,12 @@ fn exec_direct_on_connected_handle_succeeds() { // Create table and insert data via the connection directly so we can // use exec_direct for the SELECT through the FFI layer. - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE test (id INTEGER, name TEXT); \ + setup_sql( + conn, + "CREATE TABLE test (id INTEGER, name TEXT); \ INSERT INTO test VALUES (1, 'hello'); \ INSERT INTO test VALUES (2, 'world');", - ) - .expect("setup"); - } + ); let ret = exec_direct(stmt, "SELECT id, name FROM test"); assert_eq!(ret, SqlReturn::SUCCESS); @@ -145,18 +228,10 @@ fn fetch_after_exec_direct_returns_rows_then_no_data() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", - ) - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", + ); assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); @@ -186,18 +261,10 @@ fn get_data_returns_correct_values() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE t (id INTEGER, name TEXT); INSERT INTO t VALUES (42, 'test');", - ) - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE t (id INTEGER, name TEXT); INSERT INTO t VALUES (42, 'test');", + ); assert_eq!( exec_direct(stmt, "SELECT id, name FROM t"), @@ -277,35 +344,12 @@ fn get_data_datetime_column_handles_integer_and_real_storage() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - // SQLite has no real column type enforcement: the column is - // declared DATETIME, but each row is free to store whichever of - // SQLite's own three documented datetime formats it likes. Row 1 - // stores an integer (Unix epoch seconds); row 2 stores a real - // (Julian day number, SQLite's `julianday()` output format). - // - // DATETIME has no substring match in SQLite's column-affinity - // rules (no CHAR/CLOB/TEXT, INT, BLOB, or REAL/FLOA/DOUB), so it - // gets NUMERIC affinity, and NUMERIC affinity silently converts - // an inserted REAL value back to INTEGER when it has no - // fractional part. `2451545.0` would therefore actually be - // stored (and read back) as `ColumnValue::I64`, not `F64`, - // defeating the point of this row: `2451545.5` (2000-01-02 - // 00:00:00 UTC) keeps a fractional part, so SQLite is forced to - // keep it as REAL. - db.execute_batch( - "CREATE TABLE t (id INTEGER, dt DATETIME); \ + setup_sql( + conn, + "CREATE TABLE t (id INTEGER, dt DATETIME); \ INSERT INTO t VALUES (1, 1700000000); \ INSERT INTO t VALUES (2, 2451545.5);", - ) - .expect("setup"); - } + ); assert_eq!( exec_direct(stmt, "SELECT id, dt FROM t ORDER BY id"), @@ -378,16 +422,10 @@ fn get_data_col_zero_returns_error() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);") - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);", + ); assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); assert_eq!( @@ -417,16 +455,7 @@ fn num_result_cols_after_exec_direct() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE t (a INTEGER, b TEXT, c REAL)") - .expect("setup"); - } + setup_sql(conn, "CREATE TABLE t (a INTEGER, b TEXT, c REAL)"); assert_eq!( exec_direct(stmt, "SELECT a, b, c FROM t"), @@ -448,16 +477,10 @@ fn close_cursor_then_fetch_returns_no_data() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);") - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);", + ); assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); @@ -832,18 +855,10 @@ fn row_count_after_exec_direct() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", - ) - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", + ); assert_eq!(exec_direct(stmt, "SELECT id FROM t"), SqlReturn::SUCCESS); @@ -858,20 +873,14 @@ fn row_count_after_exec_direct() { /// Helper: set up a connected handle with a test table and view. unsafe fn setup_metadata_tables(conn: *mut c_void) { - let conn_handle = unsafe { - stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - } - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE test_table (id INTEGER NOT NULL, name TEXT, score REAL); + unsafe { + setup_sql( + conn, + "CREATE TABLE test_table (id INTEGER NOT NULL, name TEXT, score REAL); CREATE VIEW test_view AS SELECT id, name FROM test_table; INSERT INTO test_table VALUES (1, 'alice', 9.5);", - ) - .expect("setup"); + ) + }; } #[test] @@ -1300,16 +1309,7 @@ fn exec_direct_insert_then_select_roundtrip() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); // Set up the table via raw rusqlite so we don't burn statement state. - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") - .expect("setup"); - } + setup_sql(conn, "CREATE TABLE t (id INTEGER, name TEXT)"); // INSERT through ODBC — row count must be 1. assert_eq!( @@ -1362,21 +1362,13 @@ fn exec_direct_update_returns_correct_row_count() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); // Seed data via raw rusqlite. - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE t (id INTEGER, v INTEGER); + setup_sql( + conn, + "CREATE TABLE t (id INTEGER, v INTEGER); INSERT INTO t VALUES (1, 10); INSERT INTO t VALUES (2, 10); INSERT INTO t VALUES (3, 20);", - ) - .expect("setup"); - } + ); // UPDATE two rows through ODBC. assert_eq!( @@ -1398,21 +1390,13 @@ fn exec_direct_delete_returns_correct_row_count() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); // Seed data via raw rusqlite. - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE t (id INTEGER); + setup_sql( + conn, + "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1); INSERT INTO t VALUES (2); INSERT INTO t VALUES (3);", - ) - .expect("setup"); - } + ); // DELETE two rows through ODBC. assert_eq!( @@ -1534,8 +1518,20 @@ fn set_cursor_type_forward_only_succeeds() { } } +/// `SQL_CURSOR_STATIC` (3). Core defines only `SQL_CURSOR_FORWARD_ONLY`, +/// which is the one value this driver supports; the other three exist here so +/// this test can name what it is asking for rather than pass a bare `3`. +const SQL_CURSOR_STATIC: usize = 3; + #[test] -fn set_cursor_type_static_returns_error() { +fn set_cursor_type_static_is_substituted_with_forward_only() { + // This driver materialises every result set and walks it forward only, so + // a static cursor is not on offer. The spec has a specific answer for + // that, and it is not a refusal: 01S02 "the driver did not support the + // value specified and substituted a similar value", reported as + // SQL_SUCCESS_WITH_INFO. The application learns what it actually got by + // reading the attribute back, which is why the substituted value has to + // be observable through SQLGetStmtAttr. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -1544,10 +1540,34 @@ fn set_cursor_type_static_returns_error() { ffi::stmt_attr::sql_set_stmt_attr_w::<SqliteBackend>( stmt, StatementAttribute::CursorType as i32, - 3usize as *mut std::ffi::c_void, // SQL_CURSOR_STATIC + std::ptr::without_provenance_mut(SQL_CURSOR_STATIC), 0, ), - SqlReturn::ERROR + SqlReturn::SUCCESS_WITH_INFO, + "an unsupported cursor type is substituted, not refused" + ); + assert_eq!( + last_sqlstate(stmt), + stackable_odbc_core::types::sql_state::OPTION_VALUE_CHANGED + ); + + // `SQL_ATTR_CURSOR_TYPE` is a SQLUINTEGER attribute, so the driver + // writes exactly four bytes here whatever the buffer's width. + let mut got: u32 = u32::MAX; + let mut len: i32 = 0; + assert_eq!( + ffi::stmt_attr::sql_get_stmt_attr_w::<SqliteBackend>( + stmt, + StatementAttribute::CursorType as i32, + &raw mut got as *mut std::ffi::c_void, + std::mem::size_of::<u32>() as i32, + &mut len, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + got as usize, SQL_CURSOR_FORWARD_ONLY, + "the substituted value must be readable back" ); cleanup(env, conn, stmt); @@ -1665,16 +1685,10 @@ fn end_tran_begin_commit_roundtrip() { // Set up via raw rusqlite: create table, open a transaction, insert a row. { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( + setup_sql( + conn, "CREATE TABLE tran_test(id INTEGER); BEGIN; INSERT INTO tran_test VALUES(42);", - ) - .expect("setup"); + ); } // Commit via SQLEndTran. @@ -1720,16 +1734,10 @@ fn end_tran_begin_rollback_discards_row() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( + setup_sql( + conn, "CREATE TABLE tran_rollback(id INTEGER); BEGIN; INSERT INTO tran_rollback VALUES(99);", - ) - .expect("setup"); + ); } // Rollback via SQLEndTran. @@ -1763,16 +1771,10 @@ fn fetch_scroll_next_advances_cursor() { // Set up via raw rusqlite to avoid burning statement state. { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( + setup_sql( + conn, "CREATE TABLE scroll_test(v INTEGER); INSERT INTO scroll_test VALUES(1),(2);", - ) - .expect("setup"); + ); } assert_eq!( @@ -1829,14 +1831,7 @@ fn fetch_scroll_non_next_returns_error() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE scroll_err(v INTEGER);") - .expect("setup"); + setup_sql(conn, "CREATE TABLE scroll_err(v INTEGER);"); } assert_eq!( @@ -1862,23 +1857,17 @@ fn fetch_scroll_non_next_returns_error() { /// departments(dept_id PK, dept_name) /// employees(emp_id PK, name, dept_id FK -> departments(dept_id)) unsafe fn setup_pk_fk_schema(conn: *mut c_void) { - let conn_handle = unsafe { - stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - } - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE departments (dept_id INTEGER PRIMARY KEY, dept_name TEXT NOT NULL); - CREATE TABLE employees ( - emp_id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - dept_id INTEGER REFERENCES departments(dept_id) ON DELETE CASCADE ON UPDATE RESTRICT - );", - ) - .expect("setup pk/fk schema"); + unsafe { + setup_sql( + conn, + "CREATE TABLE departments (dept_id INTEGER PRIMARY KEY, dept_name TEXT NOT NULL); + CREATE TABLE employees ( + emp_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + dept_id INTEGER REFERENCES departments(dept_id) ON DELETE CASCADE ON UPDATE RESTRICT + );", + ) + }; } /// Helper: call SQLPrimaryKeysW and collect (table_name, col_name, key_seq) triples. @@ -2032,16 +2021,7 @@ fn sql_primary_keys_w_table_with_no_pk_returns_empty() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); // Create a table without an explicit PRIMARY KEY. - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE no_pk (val TEXT);") - .expect("setup"); - } + setup_sql(conn, "CREATE TABLE no_pk (val TEXT);"); let table = "no_pk"; let table_wide: Vec<u16> = table.encode_utf16().collect(); @@ -2219,16 +2199,7 @@ fn sql_foreign_keys_w_no_fk_table_returns_empty_for_no_refs() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); // Table with no FKs at all. - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE standalone (id INTEGER PRIMARY KEY);") - .expect("setup"); - } + setup_sql(conn, "CREATE TABLE standalone (id INTEGER PRIMARY KEY);"); let pk_table = "standalone"; let pk_wide: Vec<u16> = pk_table.encode_utf16().collect(); @@ -2368,18 +2339,10 @@ fn sql_cancel_with_open_cursor_does_not_close_it() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); // Open a result set. - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE cancel_t (id INTEGER); INSERT INTO cancel_t VALUES (1);", - ) - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE cancel_t (id INTEGER); INSERT INTO cancel_t VALUES (1);", + ); assert_eq!( exec_direct(stmt, "SELECT id FROM cancel_t"), SqlReturn::SUCCESS @@ -2578,19 +2541,11 @@ fn get_data_truncates_string_returns_success_with_info() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE trunc_test (id INTEGER, name TEXT); \ + setup_sql( + conn, + "CREATE TABLE trunc_test (id INTEGER, name TEXT); \ INSERT INTO trunc_test VALUES (1, 'hello');", - ) - .expect("setup"); - } + ); assert_eq!( exec_direct(stmt, "SELECT name FROM trunc_test"), @@ -2635,16 +2590,10 @@ fn fetch_after_no_data_returns_no_data_again() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE one_row (v INTEGER); INSERT INTO one_row VALUES (1);") - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE one_row (v INTEGER); INSERT INTO one_row VALUES (1);", + ); assert_eq!( exec_direct(stmt, "SELECT v FROM one_row"), @@ -2716,20 +2665,44 @@ fn exec_direct_reuse_after_error() { // --------------------------------------------------------------------------- #[test] -fn sql_col_attribute_w_returns_nullable() { - // SQL_DESC_NULLABLE (1008): our SQLite backend always reports nullable=1 - // (all columns nullable). This test documents that current behaviour. +fn sql_col_attribute_w_reports_each_columns_real_nullability() { + // SQL_DESC_NULLABLE (1008). All three of the spec's values are reachable, + // and which one a column gets is a fact about that column rather than a + // blanket driver answer: + // + // id INTEGER NOT NULL -> SQL_NO_NULLS + // name TEXT -> SQL_NULLABLE + // id + 1 (an expression) -> SQL_NULLABLE_UNKNOWN + // + // The third is the one worth stating. `sqlite3_table_column_metadata` + // answers nothing for a computed column, so the driver genuinely cannot + // determine it — and the spec has a value for exactly that, rather than + // requiring a guess. This driver used to report SQL_NULLABLE for all + // three. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); setup_metadata_tables(conn); assert_eq!( - exec_direct(stmt, "SELECT id, name FROM test_table"), + exec_direct(stmt, "SELECT id, name, id + 1 FROM test_table"), SqlReturn::SUCCESS ); - for col in [1u16, 2u16] { + let expected = [ + (1u16, Nullable::SqlNoNulls, "id is declared NOT NULL"), + ( + 2u16, + Nullable::SqlNullable, + "name has no NOT NULL constraint", + ), + ( + 3u16, + Nullable::SqlNullableUnknown, + "id + 1 is computed, so SQLite reports no column metadata", + ), + ]; + for (col, want, why) in expected { let mut num_attr: isize = 99; let ret = ffi::metadata::sql_col_attribute_w::<SqliteBackend>( stmt, @@ -2741,8 +2714,7 @@ fn sql_col_attribute_w_returns_nullable() { &mut num_attr, ); assert_eq!(ret, SqlReturn::SUCCESS, "col {col}"); - // SQLite backend always reports nullable=1 (SQL_NULLABLE). - assert_eq!(num_attr, 1, "col {col} should be nullable"); + assert_eq!(num_attr, want as isize, "col {col}: {why}"); } cleanup(env, conn, stmt); @@ -2823,16 +2795,10 @@ fn close_cursor_twice_returns_error() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE cc_test (v INTEGER); INSERT INTO cc_test VALUES (1);") - .expect("setup"); - } + setup_sql( + conn, + "CREATE TABLE cc_test (v INTEGER); INSERT INTO cc_test VALUES (1);", + ); assert_eq!( exec_direct(stmt, "SELECT v FROM cc_test"), @@ -3141,19 +3107,13 @@ fn bind_col_and_fetch_reads_bound_column_values() { // Insert rows via rusqlite directly. { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( + setup_sql( + conn, "CREATE TABLE bind_col_test (id INTEGER); \ INSERT INTO bind_col_test VALUES (10); \ INSERT INTO bind_col_test VALUES (20); \ INSERT INTO bind_col_test VALUES (30);", - ) - .expect("setup"); + ); } // Set SQL_ATTR_ROW_ARRAY_SIZE = 1. @@ -3240,17 +3200,11 @@ fn fetch_truncating_bound_column_reports_01004() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( + setup_sql( + conn, "CREATE TABLE trunc_test (s TEXT); INSERT INTO trunc_test VALUES ('abcdef');", - ) - .expect("setup"); + ); } assert_eq!( @@ -3320,14 +3274,7 @@ fn autocommit_off_then_rollback_discards_changes() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE tx_test (id INTEGER);") - .expect("setup"); + setup_sql(conn, "CREATE TABLE tx_test (id INTEGER);"); } assert_eq!( @@ -3361,16 +3308,7 @@ fn autocommit_off_then_rollback_discards_changes() { SqlReturn::SUCCESS ); - let count: i64 = { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.query_row("SELECT COUNT(*) FROM tx_test", [], |r| r.get(0)) - .expect("count") - }; + let count = query_scalar_i64(conn, "SELECT COUNT(*) FROM tx_test"); assert_eq!(count, 0, "rollback did not discard the inserted rows"); cleanup(env, conn, stmt); @@ -3395,14 +3333,7 @@ fn bind_parameter_prepare_execute_inserts_row() { // Create the target table via rusqlite. { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE bind_param_test (id INTEGER);") - .expect("setup"); + setup_sql(conn, "CREATE TABLE bind_param_test (id INTEGER);"); } // Set SQL_ATTR_PARAMSET_SIZE = 1. @@ -3450,23 +3381,10 @@ fn bind_parameter_prepare_execute_inserts_row() { SqlReturn::SUCCESS ); - // Verify via rusqlite that exactly one row with value 42 was inserted. - { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - let count: i64 = db - .query_row( - "SELECT COUNT(*) FROM bind_param_test WHERE id = 42", - [], - |r| r.get(0), - ) - .expect("count query"); - assert_eq!(count, 1); - } + // Verify through the driver that exactly one row with value 42 was + // inserted. + let count = query_scalar_i64(conn, "SELECT COUNT(*) FROM bind_param_test WHERE id = 42"); + assert_eq!(count, 1); cleanup(env, conn, stmt); } @@ -3479,17 +3397,11 @@ fn exec_direct_sends_bound_parameters() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( + setup_sql( + conn, "CREATE TABLE exec_direct_params (id INTEGER); INSERT INTO exec_direct_params VALUES (1), (2), (3);", - ) - .expect("setup"); + ); } let mut val: i64 = 2; @@ -3559,14 +3471,7 @@ fn bind_timestamp_and_numeric_params_are_stored_not_nulled() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE dt_test (ts TEXT, amount TEXT);") - .expect("setup"); + setup_sql(conn, "CREATE TABLE dt_test (ts TEXT, amount TEXT);"); } let sql = "INSERT INTO dt_test VALUES (?, ?)"; @@ -3631,18 +3536,8 @@ fn bind_timestamp_and_numeric_params_are_stored_not_nulled() { SqlReturn::SUCCESS ); - let (ts_stored, amount_stored): (String, String) = { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.query_row("SELECT ts, amount FROM dt_test", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) - .expect("row") - }; + let (ts_stored, amount_stored) = + query_row_two_strings(conn, "SELECT ts, amount FROM dt_test"); assert_eq!(ts_stored, "2024-01-02 10:30:15.123000000"); assert_eq!(amount_stored, "-123.45"); @@ -3667,14 +3562,7 @@ fn bulk_operations_returns_hyc00() { // Open a cursor so the handle is in a valid statement state. { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE bulkops_test (id INTEGER, val TEXT);") - .expect("setup"); + setup_sql(conn, "CREATE TABLE bulkops_test (id INTEGER, val TEXT);"); } assert_eq!( @@ -3701,16 +3589,10 @@ fn set_pos_returns_hyc00() { // Open a cursor so the handle is in a valid statement state. { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( + setup_sql( + conn, "CREATE TABLE setpos_test (id INTEGER); INSERT INTO setpos_test VALUES (1);", - ) - .expect("setup"); + ); } assert_eq!( @@ -3755,14 +3637,7 @@ fn data_at_execution_insert() { // Create target table. { - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch("CREATE TABLE dae_test (id INTEGER, name TEXT);") - .expect("setup"); + setup_sql(conn, "CREATE TABLE dae_test (id INTEGER, name TEXT);"); } // Prepare the INSERT. @@ -3986,9 +3861,15 @@ unsafe fn get_data_wchar_sized_from_metadata( /// Read the first diagnostic record's 5-character SQLSTATE off `stmt`. unsafe fn last_sqlstate(stmt: *mut c_void) -> String { + unsafe { last_diag_rec(stmt).0 } +} + +/// Read the first diagnostic record off `stmt` as (SQLSTATE, native code, +/// message). +unsafe fn last_diag_rec(stmt: *mut c_void) -> (String, i32, String) { let mut state = [0u16; 6]; let mut native: i32 = 0; - let mut msg = [0u16; 256]; + let mut msg = [0u16; 512]; let mut msg_len: i16 = 0; unsafe { assert_eq!( @@ -4006,7 +3887,53 @@ unsafe fn last_sqlstate(stmt: *mut c_void) -> String { "no diagnostic record was pushed" ); } - String::from_utf16_lossy(&state[..5]) + let len = usize::try_from(msg_len).unwrap_or(0).min(msg.len()); + ( + String::from_utf16_lossy(&state[..5]), + native, + String::from_utf16_lossy(&msg[..len]), + ) +} + +/// `SQLITE_CONSTRAINT_NOTNULL`, the extended result code SQLite reports for a +/// NOT NULL violation. The primary code is `SQLITE_CONSTRAINT` (19); the +/// extended one is what says *which* constraint failed, and is the value ODBC +/// wants in `NativeErrorPtr`. +const SQLITE_CONSTRAINT_NOTNULL: i32 = 1299; + +#[test] +fn diagnostic_carries_sqlites_own_extended_result_code() { + // Every error this driver produced used to reach the application with + // NativeErrorPtr = 0, because the `rusqlite::Error` was flattened into a + // message string at classification time and the code went with it. An + // application that wants to tell a NOT NULL violation from a foreign-key + // one reads exactly this field: both are SQLSTATE 23000. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_sql(conn, "CREATE TABLE nn (id INTEGER NOT NULL);"); + + assert_eq!( + exec_direct(stmt, "INSERT INTO nn (id) VALUES (NULL)"), + SqlReturn::ERROR + ); + + let (sqlstate, native, message) = last_diag_rec(stmt); + assert_eq!( + sqlstate, + stackable_odbc_core::types::sql_state::INTEGRITY_CONSTRAINT_VIOLATION + ); + assert_eq!( + native, SQLITE_CONSTRAINT_NOTNULL, + "SQLite's extended result code must reach NativeErrorPtr verbatim" + ); + assert!( + message.contains("NOT NULL"), + "diagnostic message should name the constraint, got {message:?}" + ); + + cleanup(env, conn, stmt); + } } #[test] @@ -4015,15 +3942,9 @@ fn metadata_sized_wchar_round_trip_covers_representative_types() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE sizing ( + setup_sql( + conn, + "CREATE TABLE sizing ( n_int INTEGER, n_real REAL, n_text TEXT, @@ -4047,9 +3968,7 @@ fn metadata_sized_wchar_round_trip_covers_representative_types() { '13:30:15.123', '2024-03-05 13:30:15.123' );", - ) - .expect("setup"); - } + ); assert_eq!( exec_direct( @@ -4230,20 +4149,12 @@ fn timestamp_column_stored_as_text_read_as_type_timestamp() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let conn_handle = stackable_odbc_core::handles::as_handle_ref::< - stackable_odbc_core::handles::ConnectionHandle<SqliteBackend>, - >(conn) - .expect("valid conn"); - { - let sqlite_conn = conn_handle.connection.as_ref().expect("connected"); - let db = sqlite_conn.conn.lock().expect("lock"); - db.execute_batch( - "CREATE TABLE ts_text (id INTEGER, dt TIMESTAMP); \ + setup_sql( + conn, + "CREATE TABLE ts_text (id INTEGER, dt TIMESTAMP); \ INSERT INTO ts_text VALUES (1, '2024-03-05 13:30:15'); \ INSERT INTO ts_text VALUES (2, 'not-a-timestamp');", - ) - .expect("setup"); - } + ); assert_eq!( exec_direct(stmt, "SELECT dt FROM ts_text ORDER BY id"), From 31a072a6fc33557dfec47270bc8f031eb04f42e0 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 27 Jul 2026 15:49:41 +0200 Subject: [PATCH 24/50] ci: make the required check observe the lint gate `pre-commit` ran in its own workflow, so the `finished` job that branch protection keys on could not `needs:` it -- `needs:` cannot cross workflows. Formatting, clippy (which is what enforces the unwrap_used / unwrap_in_result / panic denies), cargo-deny and cargo-sort could all fail while the one required check went green. Moved the job into `build.yaml` and added it to `finished`. Also: a concurrency group that supersedes in-flight runs on the same ref, but never in a merge queue, where cancelling reports failure and evicts the PR; `timeout-minutes` on every job; `--locked` on the cargo-test, cargo-clippy and cargo-deny hooks, so CI cannot quietly resolve a dependency the lockfile does not name; and a cargo-doc hook, since broken intra-doc links are warnings that would otherwise reach a published doc build unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/build.yaml | 54 +++++++++++++++++++++++++++- .github/workflows/pr_pre-commit.yaml | 46 ------------------------ .pre-commit-config.yaml | 17 +++++++-- 3 files changed, 67 insertions(+), 50 deletions(-) delete mode 100644 .github/workflows/pr_pre-commit.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6691260..37f50fd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -11,14 +11,61 @@ on: pull_request: merge_group: +# Supersede in-flight runs on the same ref. Never cancel in a merge queue: a +# cancelled merge_group run reports failure and evicts the PR from the queue. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always RUST_TOOLCHAIN_VERSION: "1.95.0" jobs: + # Formatting, clippy (which is what enforces the unwrap_used / unwrap_in_result + # / panic denies from Cargo.toml), cargo-deny, cargo-sort and shellcheck. This + # lives here rather than in its own workflow because `needs:` cannot cross + # workflows, and a lint gate the required check does not observe is not a gate. + pre-commit: + name: pre-commit + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + # The cargo-test pre-commit hook links libodbc via odbc-sys. + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + version: ubuntu-latest + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + components: rustfmt, clippy + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + - name: Install cargo-deny and cargo-sort + uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4 + with: + tool: cargo-deny,cargo-sort + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + unit-tests: name: Unit Tests runs-on: ubuntu-latest + timeout-minutes: 20 steps: # odbc-sys links against libodbc/libodbcinst, so the unixODBC dev # libraries must be present to link the test binaries (no running Driver @@ -51,6 +98,7 @@ jobs: sqlite-integration: name: SQLite Integration Tests runs-on: ubuntu-latest + timeout-minutes: 20 needs: [unit-tests] steps: - name: Install host dependencies @@ -83,6 +131,7 @@ jobs: windows-cross-compile: name: Cross-compile Windows DLL runs-on: ubuntu-latest + timeout-minutes: 20 needs: [unit-tests] steps: - name: Install MinGW cross-compiler @@ -116,14 +165,17 @@ jobs: name: Finished Build and Test if: always() needs: + - pre-commit - unit-tests - sqlite-integration - windows-cross-compile runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Check job results run: | - if [[ "${{ needs.unit-tests.result }}" != "success" ]] || + if [[ "${{ needs.pre-commit.result }}" != "success" ]] || + [[ "${{ needs.unit-tests.result }}" != "success" ]] || [[ "${{ needs.sqlite-integration.result }}" != "success" ]] || [[ "${{ needs.windows-cross-compile.result }}" != "success" ]]; then echo "One or more jobs failed" diff --git a/.github/workflows/pr_pre-commit.yaml b/.github/workflows/pr_pre-commit.yaml deleted file mode 100644 index 80289ad..0000000 --- a/.github/workflows/pr_pre-commit.yaml +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: pre-commit - -on: - pull_request: - merge_group: - -env: - CARGO_TERM_COLOR: always - RUST_TOOLCHAIN_VERSION: "1.95.0" - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - # The cargo-test pre-commit hook links libodbc via odbc-sys. - - name: Install host dependencies - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 - with: - packages: unixodbc-dev - version: ubuntu-latest - - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b - with: - toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} - components: rustfmt, clippy - - - name: Setup Rust Cache - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - - - name: Install cargo-deny and cargo-sort - uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4 - with: - tool: cargo-deny,cargo-sort - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e3a53a0..701962c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - id: cargo-test name: cargo-test language: system - entry: cargo test + entry: cargo test --locked stages: [pre-commit, pre-merge-commit] pass_filenames: false files: \.rs$|Cargo\.(toml|lock) @@ -48,11 +48,22 @@ repos: - id: cargo-clippy name: cargo-clippy language: system - entry: cargo clippy --all-targets -- -D warnings + entry: cargo clippy --locked --all-targets -- -D warnings stages: [pre-commit, pre-merge-commit] pass_filenames: false files: \.rs$ + # Broken intra-doc links are warnings, not errors, so they reach a + # published doc build silently. -D warnings promotes them. Runs in well + # under a second because the dependency graph is already built above. + - id: cargo-doc + name: cargo-doc + language: system + entry: env RUSTDOCFLAGS=-Dwarnings cargo doc --locked --no-deps + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$|Cargo\.(toml|lock) + - id: cargo-sort name: cargo-sort language: system @@ -64,7 +75,7 @@ repos: - id: cargo-deny name: cargo-deny language: system - entry: cargo deny check + entry: cargo deny --locked check stages: [pre-commit, pre-merge-commit] pass_filenames: false files: Cargo\.(toml|lock)|deny\.toml From 250a4b040c1e0c5a07f2d4d640ecfba2a52d7c30 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 27 Jul 2026 15:54:11 +0200 Subject: [PATCH 25/50] docs: correct the rules core's changes falsified, and pin the isolation check Several project rules described a world that no longer exists. `handles` is `pub(crate)` in core, so the tests cannot reach `ConnectionHandle`; the FFI helpers that replaced that route are worth documenting, including why they allocate their own statement handle instead of borrowing the caller's. `close_cursor` is fallible. Backend and StatementBackend methods have one error type. Core re-exports `odbc-sys` wholesale, which retires the `RawTimestamp` mirror `src/ffi_integration_tests.rs` carried -- a hand-maintained `#[repr(C)]` duplicate of `SQL_TIMESTAMP_STRUCT` is one drift away from a silent ABI mismatch, and there is no longer any reason to keep one. Adds the fourth capability rule: declare it once. A `SQLGetInfo` value with a `Backend` hook must not also be answered in `get_info_raw`, because core derives the info type from the hook and the two can then disagree -- `SQL_IDENTIFIER_CASE` and `SQL_GETDATA_EXTENSIONS` were both stated twice. Also covers `SQL_ATTR_TXN_ISOLATION`, which core now validates against `txn_isolation_options`: a level this driver does not advertise is refused with HY024 rather than stored and echoed back. There was no test over that attribute at all; there is now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 82 ++++++++++++++++++++---- CHANGELOG.md | 9 +++ CLAUDE.md | 18 ++++-- src/ffi_integration_tests.rs | 120 +++++++++++++++++++++++++++-------- 4 files changed, 186 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d368b8..48f7c74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,9 +121,15 @@ spec values are already modelled: All are re-exported from `stackable_odbc_core::types`. **This crate takes no direct `odbc-sys` dependency** — it reaches those types only through core's -re-exports. That is deliberate: `src/ffi_integration_tests.rs` defines a local -`RawTimestamp` mirroring `SQL_TIMESTAMP_STRUCT` rather than pull the crate in. -Do not add `odbc-sys` to `Cargo.toml`. +re-exports. Do not add `odbc-sys` to `Cargo.toml`. + +Core also re-exports the crate wholesale as `stackable_odbc_core::odbc_sys`, +so a type with no `types` re-export of its own is still reachable without a +direct dependency. Reach for that rather than hand-rolling a `#[repr(C)]` +mirror: `src/ffi_integration_tests.rs` used to carry a local `RawTimestamp` +duplicating `SQL_TIMESTAMP_STRUCT`, and a mirror that drifts from the real +struct is two different types to the compiler and one silent ABI mismatch to +the application. ### Type cast safety @@ -138,6 +144,22 @@ between `usize`, `i64`, `u16` and `i16` in this crate. Never hand-build a `SqliteError` or `OdbcError` from a `rusqlite::Error` at the call site; that function is the single place that decides the SQLSTATE. +`map_sqlite_error` keeps the `rusqlite::Error` it classified in the variant's +`cause` field, and `From<SqliteError> for OdbcError` turns that into +`with_native_error` (SQLite's *extended* result code, which is what separates +`SQLITE_CONSTRAINT_NOTNULL` from `SQLITE_CONSTRAINT_FOREIGNKEY` — the SQLSTATE +cannot) and `with_source` (the causal chain). A new classified variant must +carry `cause` too, or it silently reports native code `0`. + +**One error type, both directions.** Every `Backend` and `StatementBackend` +method returns `Result<_, SqliteError>` — core requires +`Into<OdbcError> + From<OdbcError> + Error + Send + Sync + 'static`. The +`From<OdbcError>` direction is what lets a defaulted trait body construct an +error and still name `Self::Error`, and `SqliteError::Odbc` is where such an +error lands. Return `OdbcError::NoResultSet` and friends through `.into()` +rather than reclassifying them: the round trip is lossless, and reclassifying +would discard the SQLSTATE core chose. + Convert raw integers to typed enums at the boundary with the `xxx_from_raw()` functions from core — never `transmute`. @@ -156,12 +178,22 @@ database file is `08001`. Failures after that point are `08S01`. `Backend` has around two dozen **required** methods that state what SQLite can do — `alter_table_support`, `outer_join_capabilities`, `subqueries`, -`sql_conformance`, `supports_catalogs`, `txn_isolation_options` and the rest. -They are required, with no default, deliberately: a defaulted capability is a -claim no backend ever made, and every one of them was a bug here before core -made it a compile error. - -Three rules, all learned the hard way: +`sql_conformance`, `supports_catalogs`, `identifier_case`, +`txn_isolation_options` and the rest. They are required, with no default, +deliberately: a defaulted capability is a claim no backend ever made, and every +one of them was a bug here before core made it a compile error. + +Four rules, all learned the hard way: + +**Declare it once.** A capability with a hook is answered *only* through the +hook — never also in `get_info_raw`. Core derives the info type from the hook, +so a second answer is a value that can disagree with itself, and the one an +application sees depends on which core consults first. `SQL_IDENTIFIER_CASE` +was stated in both places; so was `SQL_GETDATA_EXTENSIONS`, which is not even a +fact about SQLite — it describes core's own fetch path, and belongs to core for +the same reason. The snapshot test (`get_info_snapshot`) pins the value an +application sees regardless of who answers it, which is what makes moving an +answer safe. **Probe the bundled library, never the documentation or the system CLI.** `rusqlite` links its own SQLite (3.53.2 via the `bundled` feature); the @@ -221,8 +253,18 @@ be `SQL_CB_CLOSE`, and a COMMIT with pending writes fails with `SQLITE_BUSY`. If result sets ever become lazily streamed, both hooks must be revisited, and `SQL_CB_CLOSE` would additionally require a real -`StatementBackend::close_cursor`. `end_tran_cursor_behaviour_is_preserve_for_commit_and_rollback` -pins the reported values through the FFI entry point. +`StatementBackend::close_cursor` — which is fallible now (`Result<(), +Self::Error>`), because under `SQL_CB_CLOSE` it is the only thing that closes +the cursor during `SQLEndTran`, and a failure has to reach the statement's +diagnostic queue rather than be swallowed. Here it only resets an index into an +already-materialised `Vec`, so it cannot fail. +`end_tran_cursor_behaviour_is_preserve_for_commit_and_rollback` pins the +reported values through the FFI entry point. + +`SQL_ATTR_TXN_ISOLATION` is validated by core against `txn_isolation_options`, +which this driver answers with `SQL_TXN_SERIALIZABLE` alone. Setting any other +level is refused with `HY024` rather than stored and echoed back — see +`txn_isolation_accepts_only_the_level_sqlite_implements`. ## Architecture of this crate @@ -278,6 +320,24 @@ the real exported entry points against real handles. Prefer adding to the FFI tests when the behaviour is observable by an application: they catch the marshalling and cursor-state bugs that unit tests on the backend cannot. +Core's `conformance` module and its connection attach/detach helpers sit behind +its default-off `test-support` feature, enabled here under `[dev-dependencies]` +so `cargo test` sees it and `cargo build` does not. It is test code that would +otherwise ship inside the driver binary. + +**Set up test data through the FFI, not by reaching into the handle.** Core's +`handles` module is `pub(crate)`, so `ConnectionHandle` and the +`rusqlite::Connection` inside it are no longer reachable from here — use the +`setup_sql`, `query_scalar_i64` and `query_row_two_strings` helpers, which go +through `SQLExecDirect`/`SQLFetch`/`SQLGetData`. Each allocates its own +statement handle rather than borrowing the caller's, because the statement a +test is asserting on usually holds live state (a cursor, a prepared statement, +bound parameters) that setup would destroy. Driving setup through the driver +also means a setup path that breaks fails loudly, instead of leaving the test +asserting against an empty table. `test_support::attach_connection` is the +supported route for the different job of exercising core's connected paths +with no data source open. + ### Integration tests ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ba861..ed11b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 follows a `stackable-odbc-core` change; the driver's behaviour is unchanged beyond what it reports. +- `SQLSetConnectAttr(SQL_ATTR_TXN_ISOLATION)` refuses any level other than + `SQL_TXN_SERIALIZABLE` with SQLSTATE `HY024`, where it previously stored + whatever it was given and echoed it back. Serializable is the only level + SQLite runs at and the only one `SQL_TXN_ISOLATION_OPTION` advertises: READ + COMMITTED and REPEATABLE READ are not SQLite concepts, and READ UNCOMMITTED + needs shared-cache mode, which `connect` does not open. An application that + asked for another level previously got `SQL_SUCCESS` and serializable + behaviour regardless, with no way to learn its request had not been honoured. + - `SQL_IDENTIFIER_CASE` is now declared through the backend's `identifier_case` hook rather than answered directly. The value is unchanged (`SQL_IC_MIXED`): SQLite stores an unquoted identifier as written and matches diff --git a/CLAUDE.md b/CLAUDE.md index 73a09f9..63945d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,11 +14,21 @@ Read and follow @AGENTS.md — it contains architecture, patterns, and procedure SQLSTATEs are returned by the Driver Manager, not the driver. - **Route every client error through `map_sqlite_error`.** Never hand-build an `OdbcError` or `SqliteError` from a `rusqlite::Error` at the call site; that - function is the single place that decides the SQLSTATE. + function is the single place that decides the SQLSTATE. A new classified + variant must carry the originating error in its `cause` field, or the + diagnostic reports native code `0`. +- **One error type.** Every `Backend` and `StatementBackend` method returns + `Result<_, SqliteError>`. An `OdbcError` core produced travels back through + `SqliteError::Odbc` via `.into()` — never reclassify it, which would discard + the SQLSTATE core chose. +- **Declare each capability once.** A `SQLGetInfo` value with a `Backend` hook + is answered through the hook only, never also in `get_info_raw`. Two answers + are a value that can disagree with itself. - **Use `odbc-sys` types** — never redefine enums, structs, or constants it - already provides. They are re-exported from `stackable_odbc_core::types`. Do - **not** add `odbc-sys` as a direct dependency: this crate deliberately reaches - those types only through core's re-exports. + already provides. Reach them through `stackable_odbc_core::types`, or through + `stackable_odbc_core::odbc_sys` for anything `types` does not re-export. Do + **not** add `odbc-sys` as a direct dependency, and do not hand-roll a + `#[repr(C)]` mirror of one of its structs. - **Convert raw integers to typed enums at the boundary** — use the `xxx_from_raw()` functions from core, never `transmute`. - **Do not make result-set fetching lazy.** `exec_direct` materialises every row diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 86e7156..2718956 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -16,8 +16,9 @@ use stackable_odbc_core::{ ParamType, SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, SQL_CASCADE, SQL_CD_FALSE, SQL_CURSOR_FORWARD_ONLY, SQL_DIAG_MESSAGE_TEXT, SQL_DRIVER_ODBC_VER_STRING, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_SENSITIVE, SQL_INDEX_UNIQUE, - SQL_QUICK, SQL_RESTRICT, SqlDataType, SqlReturn, StatementAttribute, Timestamp, - expected_kind, + SQL_QUICK, SQL_RESTRICT, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, + SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, SqlReturn, StatementAttribute, + Timestamp, expected_kind, }, }; @@ -308,22 +309,6 @@ fn get_data_returns_correct_values() { } } -/// Mirrors `Timestamp` (`SQL_TIMESTAMP_STRUCT`)'s field layout so -/// this test file can read a `SQL_C_TYPE_TIMESTAMP` buffer without adding -/// `odbc-sys` as a direct dependency of this crate (it is only reached today -/// through `stackable-odbc-core`'s re-exports, none of which cover this struct). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct RawTimestamp { - year: i16, - month: u16, - day: u16, - hour: u16, - minute: u16, - second: u16, - fraction: u32, -} - /// SQLite is dynamically typed, and its own documentation defines three storage /// formats for a `DATETIME` column: ISO-8601 text, an integer count of seconds /// since the epoch, or a floating point Julian day number. Because this driver @@ -362,7 +347,7 @@ fn get_data_datetime_column_handles_integer_and_real_storage() { ffi::fetch::sql_fetch::<SqliteBackend>(stmt), SqlReturn::SUCCESS ); - let mut buf = RawTimestamp { + let mut buf = Timestamp { year: 0, month: 0, day: 0, @@ -376,8 +361,8 @@ fn get_data_datetime_column_handles_integer_and_real_storage() { stmt, 2, CDataType::TypeTimestamp as i16, - &mut buf as *mut RawTimestamp as *mut c_void, - std::mem::size_of::<RawTimestamp>() as isize, + &mut buf as *mut Timestamp as *mut c_void, + std::mem::size_of::<Timestamp>() as isize, &mut ind, ); assert_eq!(ret, SqlReturn::SUCCESS, "integer-encoded datetime"); @@ -400,8 +385,8 @@ fn get_data_datetime_column_handles_integer_and_real_storage() { stmt, 2, CDataType::TypeTimestamp as i16, - &mut buf2 as *mut RawTimestamp as *mut c_void, - std::mem::size_of::<RawTimestamp>() as isize, + &mut buf2 as *mut Timestamp as *mut c_void, + std::mem::size_of::<Timestamp>() as isize, &mut ind2, ); assert_eq!( @@ -3864,6 +3849,85 @@ unsafe fn last_sqlstate(stmt: *mut c_void) -> String { unsafe { last_diag_rec(stmt).0 } } +/// Read the first diagnostic record's 5-character SQLSTATE off a *connection* +/// handle. `SQLSetConnectAttr` posts its diagnostics there, not on a statement. +unsafe fn last_conn_sqlstate(conn: *mut c_void) -> String { + let mut state = [0u16; 6]; + let mut native: i32 = 0; + let mut msg = [0u16; 512]; + let mut msg_len: i16 = 0; + unsafe { + assert_eq!( + ffi::diag::sql_get_diag_rec_w::<SqliteBackend>( + HandleType::Dbc as i16, + conn, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + msg.len() as i16, + &mut msg_len, + ), + SqlReturn::SUCCESS, + "no diagnostic record was pushed on the connection" + ); + } + String::from_utf16_lossy(&state[..5]) +} + +#[test] +fn txn_isolation_accepts_only_the_level_sqlite_implements() { + // SQLite runs serializable and nothing else: READ COMMITTED and + // REPEATABLE READ are not SQLite concepts, and READ UNCOMMITTED needs + // shared-cache mode, which `connect` does not open. So + // SQL_TXN_ISOLATION_OPTION advertises exactly one level, and setting any + // other is now refused with HY024 rather than stored and echoed back. + // + // The spec assigns this check to the driver: the Driver Manager validates + // only attributes "that accept a discrete set of values". An application + // that asked for READ COMMITTED previously got SQL_SUCCESS and serializable + // behaviour anyway -- it had no way to find out it had not been honoured. + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<SqliteBackend>( + conn, + ConnectionAttribute::TXN_ISOLATION.0, + std::ptr::without_provenance_mut(SQL_TXN_SERIALIZABLE as usize), + 0, + ), + SqlReturn::SUCCESS, + "the one advertised level must be accepted" + ); + + for level in [ + SQL_TXN_READ_UNCOMMITTED, + SQL_TXN_READ_COMMITTED, + SQL_TXN_REPEATABLE_READ, + ] { + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<SqliteBackend>( + conn, + ConnectionAttribute::TXN_ISOLATION.0, + std::ptr::without_provenance_mut(level as usize), + 0, + ), + SqlReturn::ERROR, + "level {level:#x} is not advertised and must be refused" + ); + assert_eq!( + last_conn_sqlstate(conn), + stackable_odbc_core::types::sql_state::INVALID_ATTRIBUTE_VALUE, + "level {level:#x}" + ); + } + + cleanup(env, conn, stmt); + } +} + /// Read the first diagnostic record off `stmt` as (SQLSTATE, native code, /// message). unsafe fn last_diag_rec(stmt: *mut c_void) -> (String, i32, String) { @@ -4166,7 +4230,7 @@ fn timestamp_column_stored_as_text_read_as_type_timestamp() { ffi::fetch::sql_fetch::<SqliteBackend>(stmt), SqlReturn::SUCCESS ); - let mut buf = RawTimestamp { + let mut buf = Timestamp { year: 0, month: 0, day: 0, @@ -4180,8 +4244,8 @@ fn timestamp_column_stored_as_text_read_as_type_timestamp() { stmt, 1, CDataType::TypeTimestamp as i16, - &mut buf as *mut RawTimestamp as *mut c_void, - std::mem::size_of::<RawTimestamp>() as isize, + &mut buf as *mut Timestamp as *mut c_void, + std::mem::size_of::<Timestamp>() as isize, &mut ind, ); assert_eq!(ret, SqlReturn::SUCCESS, "text-encoded datetime"); @@ -4199,8 +4263,8 @@ fn timestamp_column_stored_as_text_read_as_type_timestamp() { stmt, 1, CDataType::TypeTimestamp as i16, - &mut buf2 as *mut RawTimestamp as *mut c_void, - std::mem::size_of::<RawTimestamp>() as isize, + &mut buf2 as *mut Timestamp as *mut c_void, + std::mem::size_of::<Timestamp>() as isize, &mut ind2, ); assert_eq!(ret2, SqlReturn::ERROR); From 0998956c91be2b870ca35a9111e19ef0f642599b Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 27 Jul 2026 16:10:16 +0200 Subject: [PATCH 26/50] ci: run the test suite once per pull request instead of three times `cargo test` ran in three places on every PR: the `unit-tests` job, the cargo-test pre-commit hook, and again inside `run-tests.sh` at the end of the integration job. Same command, same runner OS, no added coverage -- but two extra test-harness builds on two extra runners. Drops the `unit-tests` job, whose only distinguishing work was the duplicate `cargo test`; the downstream jobs now gate on `pre-commit`, which runs it via the hook. CLAUDE.md already points contributors at `pre-commit run --all-files` as the single source of truth for what must pass, and that is only true if CI runs the same thing rather than a hand-copied subset of it. Gives `run-tests.sh` a `--skip-cargo-test` flag, following the existing `--skip-build`, and passes it from CI. The default still runs everything, so a developer invoking the script by hand gets the whole suite in one command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/build.yaml | 53 ++++++++++-------------------------- AGENTS.md | 5 ++-- test/run-tests.sh | 14 ++++++++-- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 37f50fd..01305ca 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,10 +22,16 @@ env: RUST_TOOLCHAIN_VERSION: "1.95.0" jobs: - # Formatting, clippy (which is what enforces the unwrap_used / unwrap_in_result - # / panic denies from Cargo.toml), cargo-deny, cargo-sort and shellcheck. This - # lives here rather than in its own workflow because `needs:` cannot cross - # workflows, and a lint gate the required check does not observe is not a gate. + # The whole gate: `cargo test`, formatting, clippy (which is what enforces the + # unwrap_used / unwrap_in_result / panic denies from Cargo.toml), rustdoc, + # cargo-deny, cargo-sort and shellcheck. + # + # This lives here rather than in its own workflow because `needs:` cannot + # cross workflows, and a lint gate the required check does not observe is not + # a gate. It runs the hooks rather than the underlying commands so that CI and + # `pre-commit run --all-files` cannot drift apart -- CLAUDE.md points + # contributors at that command as the single source of truth for what must + # pass, which is only true if CI runs the same thing. pre-commit: name: pre-commit runs-on: ubuntu-latest @@ -62,36 +68,6 @@ jobs: - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 - unit-tests: - name: Unit Tests - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - # odbc-sys links against libodbc/libodbcinst, so the unixODBC dev - # libraries must be present to link the test binaries (no running Driver - # Manager is needed — only the libraries). - - name: Install host dependencies - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 - with: - packages: unixodbc-dev - version: ubuntu-latest - - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b - with: - toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} - - - name: Setup Rust Cache - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - - - name: Run unit tests - run: cargo test - # This suite needs only unixODBC and the sqlite3 CLI, no server and no # container, so it runs on a standard runner in seconds and is worth # gating every pull request on. @@ -99,7 +75,7 @@ jobs: name: SQLite Integration Tests runs-on: ubuntu-latest timeout-minutes: 20 - needs: [unit-tests] + needs: [pre-commit] steps: - name: Install host dependencies uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 @@ -126,13 +102,14 @@ jobs: - name: Run SQLite integration tests run: | ./test/setup.sh - ./test/run-tests.sh + # --skip-cargo-test: the pre-commit job above already ran it. + ./test/run-tests.sh --skip-cargo-test windows-cross-compile: name: Cross-compile Windows DLL runs-on: ubuntu-latest timeout-minutes: 20 - needs: [unit-tests] + needs: [pre-commit] steps: - name: Install MinGW cross-compiler run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 @@ -166,7 +143,6 @@ jobs: if: always() needs: - pre-commit - - unit-tests - sqlite-integration - windows-cross-compile runs-on: ubuntu-latest @@ -175,7 +151,6 @@ jobs: - name: Check job results run: | if [[ "${{ needs.pre-commit.result }}" != "success" ]] || - [[ "${{ needs.unit-tests.result }}" != "success" ]] || [[ "${{ needs.sqlite-integration.result }}" != "success" ]] || [[ "${{ needs.windows-cross-compile.result }}" != "success" ]]; then echo "One or more jobs failed" diff --git a/AGENTS.md b/AGENTS.md index 48f7c74..33e4449 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -342,8 +342,9 @@ with no data source open. ```bash ./test/setup.sh # build, create test/test.db, write odbc.ini/odbcinst.ini -./test/run-tests.sh # pyodbc suite through real unixODBC -./test/run-tests.sh --windows # also run the Windows VM suite +./test/run-tests.sh # pyodbc suite through real unixODBC, then cargo test +./test/run-tests.sh --windows # also run the Windows VM suite +./test/run-tests.sh --skip-cargo-test # pyodbc only; what CI passes ``` `test/setup.sh` and `test/run-tests.sh` regenerate `test/odbc.ini`, diff --git a/test/run-tests.sh b/test/run-tests.sh index d010324..f6ecb58 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -7,6 +7,7 @@ # ./test/run-tests.sh # Linux tests only # ./test/run-tests.sh --windows # Linux + Windows VM tests # ./test/run-tests.sh --skip-build # skip the cargo build (also passed to windows_test.py) +# ./test/run-tests.sh --skip-cargo-test # skip `cargo test` (CI already runs it via pre-commit) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -17,12 +18,14 @@ DB_PATH="$SCRIPT_DIR/test.db" RUN_WINDOWS=false SKIP_BUILD=false +SKIP_CARGO_TEST=false WINDOWS_EXTRA_ARGS=() for arg in "$@"; do case "$arg" in --windows) RUN_WINDOWS=true ;; --skip-build) SKIP_BUILD=true; WINDOWS_EXTRA_ARGS+=("$arg") ;; + --skip-cargo-test) SKIP_CARGO_TEST=true ;; *) WINDOWS_EXTRA_ARGS+=("$arg") ;; esac done @@ -49,9 +52,14 @@ echo "=== Running Linux pyodbc integration tests (DSN) ===" uv run --with pyodbc python3 "$SCRIPT_DIR/test_integration.py" "DSN=test_sqlite" # --- Linux: Rust FFI integration tests --- -echo "=== Running SQLite FFI integration tests ===" -cd "$PROJECT_DIR" -cargo test +# Run by default so that a developer invoking this script gets the whole suite +# in one command. CI passes --skip-cargo-test, because its pre-commit job has +# already run exactly this via the cargo-test hook, and repeating it there +# means rebuilding the test harness on a second runner for no added coverage. +if [[ "$SKIP_CARGO_TEST" == false ]]; then + echo "=== Running SQLite FFI integration tests ===" + (cd "$PROJECT_DIR" && cargo test) +fi # --- Windows VM tests (optional) --- if [[ "$RUN_WINDOWS" == true ]]; then From 9c081d5955100ec454ce3d098ca2c9bae253f41e Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Tue, 28 Jul 2026 21:11:36 +0200 Subject: [PATCH 27/50] feat!: adapt to core's per-connection and catalog reworks, and cancel for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core moved twice: capability declarations became per-connection, and the catalog functions became typed rows core owns. Both are breaking, neither has a compiling intermediate state, so they land together with the cancellation work they made possible. Per-connection capabilities. The 25 required capability methods, `get_type_info` and `escape_dialect` take `&Self::Connection` — `SQLGetInfo` is a per-connection call. Every answer this driver gives is a property of the linked SQLite rather than of the file opened, so each ignores the argument, but `sqlite_get_info` now threads `Option<&SqliteConnection>` through to `default_get_info` and `common_get_info_raw`, and an arm consulting a capability hook has to be guarded on the connection being present. That is why `SQL_MAX_CATALOG_NAME_LEN` and `SQL_MAX_SCHEMA_NAME_LEN` only report 0 once a connection is open; pre-connect they fall through to core's generic identifier length, as every other `SQL_MAX_*_NAME_LEN` already did. Catalog result sets. The six catalog methods return typed row vectors instead of a `Self::Statement`; core converts them to the spec's column layout, sorts them, and serves the result set. So `metadata.rs` loses its descriptor construction, its `statistics_sort_key`, its per-table KEY_SEQ sort and the `SQL_ALL_*` discovery blocks — core detects those enumerations on the raw arguments and answers from `supports_catalogs`, `supports_schemas` and the new required `table_types` hook. Two defects fell out of the typed rows: `PKCOLUMN_NAME` is not `Option`, which surfaced that an implicit `REFERENCES parent` was reporting NULL in a column the spec marks "not NULL" (now resolved from the parent's primary key, per position), and core's new HY009 check surfaced that a null `SQLStatistics` TableName was answering `SQL_SUCCESS` with no rows. SQLCancel. `CancelToken` is `Arc<rusqlite::InterruptHandle>` and `cancel` calls `sqlite3_interrupt`, so a statement running on one thread can be stopped from another. This is the aliasing token shape core's doc names SQLite as the example of; the handle is captured in `connect` rather than fetched through the `Mutex`, because `cancel_token` can neither block nor fail, and `cancel` takes no lock this driver owns, which is what keeps SQLCancel's idle path — where core holds the connection's group lock across the call — from deadlocking. `map_sqlite_error` gains `ErrorCode::OperationInterrupted` -> HY008, without which a cancelled statement would report HY000. Also: `SQLITE_TYPE_INFO` is a `LazyLock` because `TypeInfoRow::new` is no longer `const`; `keywords`, `search_pattern_escape`, `get_functions`, `browse_connect_attrs` and `get_type_info` return `Cow`; `ColumnDescriptor`, `TypeInfoRow` and `EscapeDialect` are read through accessors; `SQL_ADD` and `SQL_DIAG_MESSAGE_TEXT` are gone in favour of the odbc-sys values. Tests. Two pinned defects core has since fixed, so both are rewritten rather than kept: `set_query_timeout_stored_and_retrieved` asserted that a 30-second timeout was stored and echoed back, and `sql_statistics_w_no_table_filter_also_succeeds` asserted the null-TableName success. `metadata.rs`'s unit tests move off `StatementBackend` onto the typed rows and assert only which rows exist and what each field holds; ordering is core's now, so it is asserted at the FFI level where core's sort has actually run. The cross-thread cancel test was verified by mutation: with `token.interrupt()` removed the query runs to completion (44s) and the test fails on the return code. Verified against stackable-odbc-core b047cb1: cargo test 274 passing, clippy clean, pre-commit green, and the pyodbc suite 23/23 DSN-less and 23/23 via DSN. Core has since landed 85a705a and 565f782, which change `Backend::tables` to take a parsed `&[String]` table-type list, so this does not build against core's current HEAD; that adaptation is deliberately left for a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 122 +++- CHANGELOG.md | 94 +++ src/backend.rs | 397 +++++++++-- src/backend/execute.rs | 10 +- src/backend/info.rs | 595 +++++++++------- src/backend/metadata.rs | 1269 +++++++++++++++------------------- src/escape_dialect.rs | 6 +- src/ffi_integration_tests.rs | 451 +++++++++++- 8 files changed, 1848 insertions(+), 1096 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 33e4449..1e1c33b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ the 73 C ABI entry points — lives in | [Backend error mapping](#backend-error-mapping) | Touching an error path | | [Declaring capabilities](#declaring-capabilities) | Adding or changing any `SQLGetInfo` value | | [Transactions](#transactions) | Touching `SQLEndTran`, autocommit or cursor behaviour | +| [Cancellation](#cancellation) | Touching `SQLCancel` or `SQL_ATTR_QUERY_TIMEOUT` | +| [Catalog functions](#catalog-functions) | Touching anything in `metadata.rs` | | [Architecture](#architecture-of-this-crate) | Understanding the module layout | | [Connection string keys](#connection-string-keys) | Adding or changing a parameter | | [Testing](#testing) | Writing or running tests | @@ -58,7 +60,8 @@ crates.io; releases are GitHub Release archives built by | `Backend` / `StatementBackend` trait definitions | core | | Opening the database, executing, fetching | this crate | | SQLite storage class → SQL type mapping, value conversion | this crate | -| Catalog and metadata queries | this crate | +| Querying SQLite for catalog metadata | this crate — see [Catalog functions](#catalog-functions) | +| Catalog column layout, sort order, the `SQL_ALL_*` enumerations | core | | Connection-string parsing | this crate | | ODBC escape-sequence translation | this crate | @@ -179,9 +182,31 @@ database file is `08001`. Failures after that point are `08S01`. `Backend` has around two dozen **required** methods that state what SQLite can do — `alter_table_support`, `outer_join_capabilities`, `subqueries`, `sql_conformance`, `supports_catalogs`, `identifier_case`, -`txn_isolation_options` and the rest. They are required, with no default, -deliberately: a defaulted capability is a claim no backend ever made, and every -one of them was a bug here before core made it a compile error. +`txn_isolation_options`, `table_types` and the rest. They are required, with no +default, deliberately: a defaulted capability is a claim no backend ever made, +and every one of them was a bug here before core made it a compile error. +`table_types` is required for the same reason and one of its own: an empty +table-type list is an *answer* ("this data source has no table types"), not +"unknown", and unlike catalogs and schemas there is no `supports_*` method for +core to derive it from. + +They all take `&Self::Connection`, because `SQLGetInfo` is a per-connection +call and a data source's capabilities can differ by server. Every one this +driver declares is a property of the SQLite `rusqlite` links, not of the file +opened, so each ignores the argument — but the answer must still be read +through a connection, and the tests do that via `info::tests::test_connection` +rather than calling the hook as a free function. `cursor_commit_behavior`, +`cursor_rollback_behavior` and `catalog_result_column_widths` are the +exceptions and take none: `SQLGetInfo` must answer the first two before a +connection exists. + +The same split runs through `get_info`. `sqlite_get_info` takes +`Option<&SqliteConnection>` — `None` on the pre-connect path — and hands it to +`default_get_info` / `common_get_info_raw`, which answer only what is knowable +without a data source and leave the rest. An arm that consults a capability +hook must therefore be guarded on the connection being present, which is why +`SQL_MAX_CATALOG_NAME_LEN` and `SQL_MAX_SCHEMA_NAME_LEN` only report `0` once +one is open. Four rules, all learned the hard way: @@ -263,8 +288,57 @@ reported values through the FFI entry point. `SQL_ATTR_TXN_ISOLATION` is validated by core against `txn_isolation_options`, which this driver answers with `SQL_TXN_SERIALIZABLE` alone. Setting any other -level is refused with `HY024` rather than stored and echoed back — see -`txn_isolation_accepts_only_the_level_sqlite_implements`. +level on an open connection is refused with `HY024` rather than stored and +echoed back — see `txn_isolation_accepts_only_the_level_sqlite_implements`. +Because `txn_isolation_options` is a per-connection hook, a level set *before* +connecting is only checked for naming exactly one level; the comparison against +the hook happens at connect time, so an unsupported level fails the connect. + +### Cancellation + +`SQLCancel` is real: `Backend::CancelToken` is `Arc<rusqlite::InterruptHandle>` +and `cancel` calls `sqlite3_interrupt`, which stops the in-flight +`sqlite3_step` on that connection. + +This is the **aliasing** token shape of the two `Backend::CancelToken`'s doc +comment describes — the token refers to the same connection the statement is +executing on — and it is sound only because SQLite documents +`sqlite3_interrupt` as safe to call from another thread. The `Arc` is core's +requirement for that shape: core clones the token out of its registry before +touching anything else, so the token has to survive a concurrent +`SQLDisconnect`. `rusqlite` already satisfies the underlying rule — its +`InterruptHandle` holds an `Arc<Mutex<*mut sqlite3>>` shared with the +connection, and `InnerConnection::close` nulls that pointer while holding the +same mutex, so a racing `interrupt()` either finds a live handle or finds null +and does nothing. + +Three things this depends on, in order: + +- **The handle is captured in `connect`,** not fetched on demand. + `cancel_token` can neither block nor fail, and the `rusqlite::Connection` + lives behind a `Mutex` — reaching through it would mean waiting on whatever + thread is executing. Core's own doc asks for the same thing for a different + reason: assemble the token with the connection in hand, never lazily inside + `cancel`. +- **`cancel` takes no lock this driver owns.** On `SQLCancel`'s idle path core + holds the connection's group lock across the call, so anything that waited on + it would deadlock. `interrupt()` takes only `rusqlite`'s own short-lived + interrupt lock, which no ODBC entry point holds. +- **`SQLITE_INTERRUPT` maps to `HY008`.** `map_sqlite_error` classifies + `ErrorCode::OperationInterrupted` as `SqliteError::OperationCanceled`, which + is the SQLSTATE the spec's diagnostics tables list for a statement stopped by + `SQLCancel`. Without that arm a cancelled statement would report `HY000`. + +`sql_cancel_from_another_thread_stops_a_running_statement` drives the real +entry points across two threads. It was verified by mutation: with +`token.interrupt()` removed the query runs to completion and the test fails on +the return code. Note the gate it holds — `SQLCancel`'s idle branch clears the +statement's diagnostic queue, so a cancel landing after `SQLExecDirectW` +returns would wipe the `HY008` the test is reading. + +`SQL_ATTR_QUERY_TIMEOUT` is still substituted with `0` and reported as `01S02`. +Cancellation is a signal from another thread; a timeout would need a deadline +this driver's synchronous execute path has nothing to arm. ## Architecture of this crate @@ -274,7 +348,7 @@ level is refused with `HY024` rather than stored and echoed back — see | `src/backend.rs` | `SqliteBackend`, `SqliteConnection`, `SqliteStatement`, `SqliteError`, `map_sqlite_error` | | `src/backend/execute.rs` | `exec_direct`, `prepare`, `execute`, and the `StatementBackend` impl | | `src/backend/info.rs` | `SQLGetInfo` answers and the capability bitmaps, plus the snapshot test | -| `src/backend/metadata.rs` | The catalog functions: tables, columns, primary keys, statistics, special columns | +| `src/backend/metadata.rs` | The catalog row producers: tables, columns, primary keys, statistics, special columns | | `src/backend/params.rs` | Parameter binding | | `src/backend/types/connect_params.rs` | `SqliteConnectParams` | | `src/escape_dialect.rs` | ODBC escape-sequence translation for SQLite's dialect | @@ -292,6 +366,40 @@ This is load-bearing well beyond memory use. It is why the cursor-behaviour hooks report `Preserve`, why `SQLEndTran` cannot disturb a cursor, and why concurrency is a non-issue. Changing it is not a local optimisation. +### Catalog functions + +The six catalog methods return **typed row vectors** — `Vec<TableRow>`, +`Vec<ColumnRow>`, `Vec<PrimaryKeyRow>`, `Vec<ForeignKeyRow>`, +`Vec<StatisticsRow>`, `Vec<SpecialColumnRow>` — not a `Self::Statement`. Core +converts each row to the spec's column layout, sorts the set into the order +that function's spec page mandates, and serves it. Three consequences for +anything changed in `metadata.rs`: + +- **Do not sort, and do not add an `ORDER BY` for ODBC's sake.** Core sorts, + stably, on the spec's keys. A second ordering in the backend is one more + place for it to be wrong, and it silently overrides nothing — core re-sorts + regardless. The one thing to keep in mind is that the sort takes NULL + placement from `Backend::null_collation`, which is why `SQLStatistics`' + table-stat row (NULL `NON_UNIQUE`) still comes first: this driver reports + `SQL_NC_LOW`. +- **Do not handle the `SQL_ALL_*` enumerations.** `SQL_ALL_CATALOGS`, + `SQL_ALL_SCHEMAS` and `SQL_ALL_TABLE_TYPES` are all the same `"%"` sentinel, + distinguished by which argument carries it while the others are empty + strings. Core detects them on the *raw* arguments before calling `tables`, + and answers from `supports_catalogs`, `supports_schemas` and `table_types`. + `catalogs` and `schemas` are left defaulted here because the first two hooks + say SQLite has neither, so core never asks. +- **A non-`Option` field is a column the spec marks "not NULL".** The types + enforce it, which is how `SQLForeignKeys`' `PKCOLUMN_NAME` stopped being + reported as NULL for a `REFERENCES parent` with no column list — SQLite + defines that as the parent's primary key, so `parent_pk_column` resolves the + name rather than dropping it. + +Because ordering is core's, an ordering assertion belongs in +`ffi_integration_tests.rs`, where core's sort has actually run — the unit tests +in `metadata.rs` assert only which rows exist and what each field holds. See +`sql_statistics_w_orders_table_stat_row_first_then_unique_before_non_unique`. + ## Connection string keys Keys are matched case-insensitively and stored lowercase by core's diff --git a/CHANGELOG.md b/CHANGELOG.md index ed11b6b..16e77df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `SQLCancel` actually cancels. A statement running on one thread can be + stopped from another, which is the case the spec singles out: the driver now + holds `sqlite3_interrupt`'s handle for the connection and calls it, so the + in-flight query fails with SQLSTATE `HY008` ("operation canceled") instead of + running to completion. It previously reported "not implemented", which + `SQLCancel` treats as success — an application that asked to stop a runaway + query got `SQL_SUCCESS` and then waited for it anyway. Cancelling an idle + statement is still a no-op and still succeeds, per spec, and a cancelled + statement can be re-executed. + + `SQL_ATTR_QUERY_TIMEOUT` is unaffected and still substituted with `0`: + cancellation is a signal from another thread, whereas a timeout would need a + deadline this driver's synchronous execution path has nothing to arm. + +- `SQLTables` answers the `SQL_ALL_CATALOGS`, `SQL_ALL_SCHEMAS` and + `SQL_ALL_TABLE_TYPES` enumerations, which is how a BI tool's navigator + browses a data source. `SQL_ALL_TABLE_TYPES` reports `TABLE` and `VIEW`, the + two values `SQLTables` can put in `TABLE_TYPE`; the other two are empty + result sets, SQLite having neither catalogs nor schemas. The driver used to + answer the table-type case itself and now declares the list through the new + `Backend::table_types` hook, with `stackable-odbc-core` detecting all three + enumerations and serving them — including the distinction that makes them + work, since all three sentinels are the same `"%"` and differ only in which + argument carries it while the others are empty strings. + +- `SQL_ATTR_ROWS_FETCHED_PTR`, `SQL_ATTR_ROW_STATUS_PTR` and + `SQL_ATTR_ROW_BIND_OFFSET_PTR` are honoured instead of accepted and ignored. + With `SQL_ATTR_ROW_ARRAY_SIZE` pinned at 1 the rowset holds exactly one row, + so the fetched count is 1 per row and 0 at `SQL_NO_DATA`, the status is + `SQL_ROW_SUCCESS` (or `SQL_ROW_SUCCESS_WITH_INFO` when the row raised + `01004`), and the bind offset is added to every bound column and indicator + address on each fetch. This follows a `stackable-odbc-core` change. + +- `SQLGetData` retrieves a long character or binary value in parts, returning + `SQL_SUCCESS_WITH_INFO` with `01004` and resuming from the read position on + the next call, rather than restarting from the beginning each time. This + follows a `stackable-odbc-core` change. + - Diagnostics now carry SQLite's own error code and the failure that caused them. `map_sqlite_error` keeps the `rusqlite::Error` it classified rather than flattening it into a message, so `SQLGetDiagRec` reports SQLite's @@ -54,6 +92,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dispatch table from, so naming a function core does not export would hand it a null pointer. A test keeps the historical list checked against core's. +- `SQLSetStmtAttr(SQL_ATTR_QUERY_TIMEOUT)` and + `SQLSetStmtAttr(SQL_ATTR_MAX_ROWS)` now substitute `0` and return + `SQL_SUCCESS_WITH_INFO` with SQLSTATE `01S02` for any other value, where both + were previously stored and echoed back by `SQLGetStmtAttr`. Both are on the + spec's `01S02` substitution list. Nothing in this driver counts rows or + enforces a deadline — `Backend` is synchronous and `SQLCancel` is not + implemented — so an application that set a 30-second timeout and got + `SQL_SUCCESS` would wait indefinitely on a runaway query. Setting either to + `0` still succeeds plainly, that being the value the driver honours. This + follows a `stackable-odbc-core` change. + +- An infinite `REAL` read as `SQL_C_CHAR` or `SQL_C_WCHAR` now renders as + `Infinity` / `-Infinity` rather than `inf` / `-inf`. Both spellings parse + back into a float, and `Infinity` is what Trino, its JDBC driver and + PostgreSQL emit; the ODBC spec defines no textual form for a non-finite + float. `NaN` is unchanged. This follows a `stackable-odbc-core` change to its + shared coercion path. + - `SQLSetStmtAttr(SQL_ATTR_CURSOR_TYPE)` with an unsupported cursor type now substitutes `SQL_CURSOR_FORWARD_ONLY` and returns `SQL_SUCCESS_WITH_INFO` with SQLSTATE `01S02` ("option value changed"), where it previously failed @@ -168,6 +224,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `SQLForeignKeys` reported `PKCOLUMN_NAME` as NULL for a foreign key declared + without an explicit column list (`REFERENCES parent`), a column the spec + marks "not NULL". SQLite defines the implicit target as the parent table's + primary key, so the name is now resolved from it — per position, for a + composite key — rather than dropped. `PRAGMA foreign_key_list` leaves its + `to` column NULL in that case, which is what the old code passed straight + through. + +- `SQLStatistics` with a null `TableName` returned `SQL_SUCCESS` and an empty + result set, which an application reads as "that table has no indexes". It is + now `HY009`. `SQLStatistics` is one of only two catalog functions whose + null-`TableName` clause carries no **(DM)** marker, so the driver owns it + rather than the Driver Manager. An empty-string `TableName` is still a legal + argument naming no table, and still returns no rows. + +- Every catalog result set is now sorted into the order its spec page + mandates, by `stackable-odbc-core`, which holds the rows. `SQLTables`, + `SQLColumns`, `SQLPrimaryKeys`, `SQLForeignKeys` and `SQLSpecialColumns` were + previously returned in whatever order the underlying `sqlite_master` or + `PRAGMA` query produced, which matched the spec only by accident; + `SQLStatistics` sorted itself. Integer key columns (`KEY_SEQ`, + `ORDINAL_POSITION`) compare numerically, so a table with more than nine + columns no longer sorts column 10 before column 2. + +- `SQLDescribeCol` reported `2^64 - 4` as the column size of an unbounded + column instead of `0`, `SQLGetInfoW` wrote four bytes into the two-byte + buffer an application supplies for four `SQLUSMALLINT` info types, and a + parameter bound `SQL_PARAM_OUTPUT` had its buffer read as an input value. + `SQLAllocHandle`, `SQLFreeHandle` and `SQLFreeStmt` now post a diagnostic on + failure rather than returning a bare `SQL_ERROR` with nothing for + `SQLGetDiagRec` to report. All follow `stackable-odbc-core` fixes. + - `SQL_MAX_COLUMNS_IN_SELECT`, `_IN_TABLE`, `_IN_GROUP_BY`, `_IN_ORDER_BY`, `_IN_INDEX`, `SQL_MAX_STATEMENT_LEN` and `SQL_MAX_ROW_SIZE` now report the connection's actual limits instead of `0`. The spec allows `0` for "no @@ -184,6 +272,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead of the generic identifier length. This driver supports neither catalogs nor schemas, so there is no name for these to bound; they were stating a maximum length for something the same driver says does not exist. + Both answers are derived from `supports_catalogs` / `supports_schemas` rather + than pinned to `0`, so they stay right if either hook flips — and because + those hooks are per-connection, the `0` applies once a connection is open. + Asked before `SQLDriverConnectW`, both fall through to + `stackable-odbc-core`'s generic identifier length, the same answer it gives + pre-connect for every other `SQL_MAX_*_NAME_LEN`. - `SQL_SUBQUERIES` no longer claims `SQL_SQ_QUANTIFIED`. `< ALL`, `< ANY` and `< SOME` are all syntax errors in SQLite, which this driver already recorded diff --git a/src/backend.rs b/src/backend.rs index 7402959..1e4f60c 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,13 +1,17 @@ -use std::sync::Mutex; +use std::{ + borrow::Cow, + sync::{Arc, Mutex}, +}; use snafu::Snafu; use stackable_odbc_core::{ backend::Backend, errors::OdbcError, types::{ - ColumnDescriptor, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, InfoValue, - SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, - SQL_TXN_SERIALIZABLE, TypeInfoRow, + ColumnDescriptor, ColumnRow, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, + ForeignKeyRow, InfoValue, PrimaryKeyRow, SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, + SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TXN_SERIALIZABLE, SpecialColumnRow, + StatisticsRow, TableRow, TypeInfoRow, }, }; @@ -35,6 +39,17 @@ pub struct SqliteBackend; pub struct SqliteConnection { pub conn: Mutex<rusqlite::Connection>, + /// `sqlite3_interrupt`'s handle for this connection, captured in + /// [`SqliteBackend::connect`] and handed to every statement as its cancel + /// token. See [`SqliteBackend::CancelToken`]. + /// + /// Held here rather than taken from `conn` on demand because + /// [`Backend::cancel_token`] cannot fail and cannot block: reaching through + /// the `Mutex` would mean either waiting on whatever thread is executing or + /// inventing an answer for a poisoned lock. Capturing it once at connect + /// time is also what core's `cancel_token` doc asks for — assemble the + /// token with the connection in hand, never lazily inside `cancel`. + pub(crate) interrupt: Arc<rusqlite::InterruptHandle>, /// True while the application has turned autocommit off. `end_tran` reads /// this to decide whether to open the next transaction after committing. pub(crate) manual_commit: std::sync::atomic::AtomicBool, @@ -159,8 +174,24 @@ pub enum SqliteError { message: String, cause: Option<rusqlite::Error>, }, + /// `SQLITE_INTERRUPT`: the statement was stopped by `sqlite3_interrupt`, + /// which for this driver means `SQLCancel`. See + /// [`SqliteBackend::cancel`]. + #[snafu(display("operation canceled: {message}"))] + OperationCanceled { + message: String, + cause: Option<rusqlite::Error>, + }, } +/// Operation canceled — `HY008`. +/// +/// The SQLSTATE the spec lists for every function that can be stopped by +/// `SQLCancel` (`SQLExecDirect`, `SQLExecute`, `SQLFetch`, the catalog +/// functions). `stackable-odbc-core` has no named constructor for it, so it is +/// declared here rather than written as a bare literal at the use site. +pub(crate) const SQL_STATE_OPERATION_CANCELED: &str = "HY008"; + /// SQLite's extended result code for `e`, or `0` when there is none. /// /// The extended code is the useful one: it distinguishes @@ -210,6 +241,15 @@ pub(crate) fn map_sqlite_error(e: rusqlite::Error) -> SqliteError { message, cause: Some(e), }, + // SQLITE_INTERRUPT. `sqlite3_interrupt` is only ever called by + // this driver's `SQLCancel` implementation, so this is a + // cancelled statement rather than a failure of the data + // source, and `HY008` is what the spec's diagnostics tables + // list for exactly that. + ErrorCode::OperationInterrupted => SqliteError::OperationCanceled { + message, + cause: Some(e), + }, // SQLITE_ERROR covers syntax errors and unresolved names alike. ErrorCode::Unknown => classify_sqlite_error_message(message, Some(e)), _ => SqliteError::Rusqlite { source: e }, @@ -311,6 +351,9 @@ impl From<SqliteError> for OdbcError { SqliteError::NumericOutOfRange { cause, .. } => { (SqlState::numeric_value_out_of_range(), cause) } + SqliteError::OperationCanceled { cause, .. } => { + (SqlState::new(SQL_STATE_OPERATION_CANCELED), cause) + } SqliteError::Rusqlite { source } => (SqlState::general_error(), Some(source)), SqliteError::MissingParam { .. } | SqliteError::General { .. } => { (SqlState::general_error(), None) @@ -331,10 +374,56 @@ impl From<SqliteError> for OdbcError { } impl Backend for SqliteBackend { + /// `sqlite3_interrupt`'s handle, the *aliasing* token shape + /// [`Backend::CancelToken`] names SQLite as the example of: it refers to + /// the same connection the statement is executing on, which is sound only + /// because SQLite documents `sqlite3_interrupt` as safe to call from a + /// thread other than the one running the query. + /// + /// The `Arc` is the requirement core states for an aliasing token — it has + /// to survive a concurrent `SQLDisconnect`, because core clones the token + /// out before doing anything else. `rusqlite`'s `InterruptHandle` already + /// satisfies the underlying rule ("it is not safe to call this routine with + /// a database connection that is closed or might close before + /// `sqlite3_interrupt()` returns"): it holds an + /// `Arc<Mutex<*mut sqlite3>>` shared with the connection, and + /// `InnerConnection::close` nulls that pointer *while holding the same + /// mutex*, so a racing `interrupt()` either runs against a live handle or + /// sees null and does nothing. Wrapping it in this crate's own `Arc` is + /// what makes the token cheap to clone per statement. + type CancelToken = Arc<rusqlite::InterruptHandle>; type Connection = SqliteConnection; type Error = SqliteError; type Statement = SqliteStatement; + /// Hand out the connection's interrupt handle. Infallible and lock-free: + /// the handle was captured in [`SqliteBackend::connect`], so this only + /// bumps a refcount — see [`SqliteConnection::interrupt`]. + fn cancel_token(conn: &SqliteConnection) -> Arc<rusqlite::InterruptHandle> { + Arc::clone(&conn.interrupt) + } + + /// Interrupt whatever is running on the token's connection. + /// + /// `sqlite3_interrupt` makes the in-flight `sqlite3_step` return + /// `SQLITE_INTERRUPT`, which surfaces from + /// [`stackable_odbc_core::backend::Backend::exec_direct`] and friends as + /// `HY008` ("operation canceled") via `map_sqlite_error` — the SQLSTATE the + /// spec defines for a statement stopped by `SQLCancel`. + /// + /// Safe on both of `SQLCancel`'s paths. It never blocks on this + /// connection's own `Mutex`, so the idle path — where core holds the + /// connection's group lock across this call — cannot deadlock; the only + /// lock taken is `rusqlite`'s short-lived interrupt lock, which no ODBC + /// entry point holds. It is also a no-op rather than an error when nothing + /// is running, which is exactly what the spec asks of `SQLCancel` in that + /// case. + fn cancel(token: &Arc<rusqlite::InterruptHandle>) -> Result<(), SqliteError> { + tracing::debug!("SQLCancel: interrupting the SQLite connection"); + token.interrupt(); + Ok(()) + } + fn connect(params: &ConnectParams) -> Result<SqliteConnection, SqliteError> { let p = types::connect_params::SqliteConnectParams::try_from(params)?; let conn = rusqlite::Connection::open(p.database()).map_err(map_sqlite_error)?; @@ -358,8 +447,14 @@ impl Backend for SqliteBackend { conn.execute_batch("PRAGMA foreign_keys = ON") .map_err(map_sqlite_error)?; + // Captured before the connection moves into the `Mutex`: after that, + // reaching it would mean taking a lock, and `cancel_token` can neither + // block nor fail. See `SqliteConnection::interrupt`. + let interrupt = Arc::new(conn.get_interrupt_handle()); + Ok(SqliteConnection { conn: Mutex::new(conn), + interrupt, manual_commit: std::sync::atomic::AtomicBool::new(false), }) } @@ -368,8 +463,8 @@ impl Backend for SqliteBackend { Ok(()) // rusqlite closes on drop } - fn browse_connect_attrs() -> &'static [&'static str] { - &["database"] + fn browse_connect_attrs() -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(&[Cow::Borrowed("database")]) } /// SQLite supports transactions and this driver reports `SQL_TC_DML` for @@ -465,7 +560,7 @@ impl Backend for SqliteBackend { /// which is `SQL_IC_SENSITIVE` here: a quoted `"T"` does not match `"t"`. /// /// <https://sqlite.org/lang_keywords.html> - fn identifier_case() -> u16 { + fn identifier_case(_conn: &SqliteConnection) -> u16 { SQL_IC_MIXED } @@ -480,7 +575,7 @@ impl Backend for SqliteBackend { /// the other two inherit defaults that named a catalog, telling an /// application catalogs do not exist and giving their name in the same /// breath. - fn supports_catalogs() -> bool { + fn supports_catalogs(_conn: &SqliteConnection) -> bool { false } @@ -489,20 +584,20 @@ impl Backend for SqliteBackend { /// /// Drives `SQL_SCHEMA_TERM` and `SQL_SCHEMA_USAGE`; see /// [`SqliteBackend::supports_catalogs`]. - fn supports_schemas() -> bool { + fn supports_schemas(_conn: &SqliteConnection) -> bool { false } /// The `ALTER TABLE` clauses SQLite accepts, of those the ODBC bitmap can /// express. See `info::SQLITE_ALTER_TABLE` for what is claimed, what is /// supported-but-unrepresentable, and how each bit was verified. - fn alter_table_support() -> u32 { + fn alter_table_support(_conn: &SqliteConnection) -> u32 { info::SQLITE_ALTER_TABLE } /// Every outer-join form SQLite implements. See /// `info::SQLITE_OUTER_JOIN_CAPABILITIES`. - fn outer_join_capabilities() -> u32 { + fn outer_join_capabilities(_conn: &SqliteConnection) -> u32 { info::SQLITE_OUTER_JOIN_CAPABILITIES } @@ -513,7 +608,7 @@ impl Backend for SqliteBackend { /// connection from this, so the two cannot disagree. /// /// Spec: <https://www.sqlite.org/isolation.html> - fn default_txn_isolation() -> u32 { + fn default_txn_isolation(_conn: &SqliteConnection) -> u32 { SQL_TXN_SERIALIZABLE } @@ -529,7 +624,7 @@ impl Backend for SqliteBackend { /// [`Backend::set_txn_isolation`] is correct as-is: the one supported /// level is always already in effect, and anything else is rejected with /// `HY024` before it reaches the backend. - fn txn_isolation_options() -> u32 { + fn txn_isolation_options(_conn: &SqliteConnection) -> u32 { SQL_TXN_SERIALIZABLE } @@ -539,30 +634,30 @@ impl Backend for SqliteBackend { /// `GROUP BY` columns and expressions absent from the select list. /// /// Verified in `group_by_is_unrelated_to_the_select_list`. - fn group_by() -> u16 { + fn group_by(_conn: &SqliteConnection) -> u16 { SQL_GB_NO_RELATION } /// `SQL_NC_LOW`: SQLite sorts NULLs at the low end — first ascending, last /// descending. - fn null_collation() -> u16 { + fn null_collation(_conn: &SqliteConnection) -> u16 { SQL_NC_LOW } /// `SQL_CN_ANY`: SQLite accepts a table alias with or without `AS`, and /// places no restriction on the name. - fn correlation_name() -> u16 { + fn correlation_name(_conn: &SqliteConnection) -> u16 { SQL_CN_ANY } /// `SQL_NNC_NON_NULL`: SQLite implements `NOT NULL` column constraints. - fn non_nullable_columns() -> u16 { + fn non_nullable_columns(_conn: &SqliteConnection) -> u16 { SQL_NNC_NON_NULL } /// SQLite takes arbitrary expressions in `ORDER BY`, including over columns /// absent from the select list. - fn expressions_in_order_by() -> bool { + fn expressions_in_order_by(_conn: &SqliteConnection) -> bool { true } @@ -581,55 +676,55 @@ impl Backend for SqliteBackend { /// `0` is the honest answer: it claims no level rather than asserting one /// the driver demonstrably fails. Raising it later means auditing SQL-92 /// entry level properly, not restoring the value core used to invent. - fn sql_conformance() -> u32 { + fn sql_conformance(_conn: &SqliteConnection) -> u32 { 0 } /// `0`: `TIMESTAMPADD` is not supported. `SQLITE_TIMEDATE_FUNCTIONS` /// deliberately omits `SQL_FN_TD_TIMESTAMPADD`, so claiming interval units /// here would describe a function this driver does not offer. - fn timedate_add_intervals() -> u32 { + fn timedate_add_intervals(_conn: &SqliteConnection) -> u32 { 0 } /// `0`: `TIMESTAMPDIFF` is not supported, for the same reason as /// [`SqliteBackend::timedate_add_intervals`]. - fn timedate_diff_intervals() -> u32 { + fn timedate_diff_intervals(_conn: &SqliteConnection) -> u32 { 0 } /// See `info::SQLITE_SUBQUERIES`. Notably excludes `SQL_SQ_QUANTIFIED`, /// which core's default claimed while this driver's /// `SQL_SQL92_PREDICATES` denied it. - fn subqueries() -> u32 { + fn subqueries(_conn: &SqliteConnection) -> u32 { info::SQLITE_SUBQUERIES } /// SQLite accepts `SELECT a AS x`, and `AS` is optional. - fn column_alias() -> bool { + fn column_alias(_conn: &SqliteConnection) -> bool { true } /// `SQL_CB_NULL`: concatenating a NULL yields NULL — `'a' || NULL` is /// NULL, not `'a'`. - fn concat_null_behavior() -> u16 { + fn concat_null_behavior(_conn: &SqliteConnection) -> u16 { SQL_CB_NULL } /// See `info::SQLITE_UNION` — both `UNION` and `UNION ALL`. - fn union_support() -> u32 { + fn union_support(_conn: &SqliteConnection) -> u32 { info::SQLITE_UNION } /// See `info::SQLITE_CONVERT_FUNCTIONS` — `CAST` only. - fn convert_functions() -> u32 { + fn convert_functions(_conn: &SqliteConnection) -> u32 { info::SQLITE_CONVERT_FUNCTIONS } /// `false`: SQLite orders by expressions and by columns absent from the /// select list, so `ORDER BY` is not restricted to selected columns. Same /// permissiveness as [`SqliteBackend::group_by`]. - fn order_by_columns_in_select() -> bool { + fn order_by_columns_in_select(_conn: &SqliteConnection) -> bool { false } @@ -640,7 +735,7 @@ impl Backend for SqliteBackend { /// This is the one value in this group that is a claim about the connected /// principal rather than about SQL. It is safe here precisely because /// SQLite has no principal. - fn accessible_tables() -> bool { + fn accessible_tables(_conn: &SqliteConnection) -> bool { true } @@ -650,7 +745,7 @@ impl Backend for SqliteBackend { /// read-only media, or one whose file permissions deny writes, still /// reports `false` here and fails the write itself — which is what the /// spec's "data source is set to READ ONLY mode" means. - fn data_source_read_only() -> bool { + fn data_source_read_only(_conn: &SqliteConnection) -> bool { false } @@ -660,30 +755,39 @@ impl Backend for SqliteBackend { /// This is the raw list; core subtracts `ODBC_RESERVED_KEYWORDS` and joins /// it into `SQL_KEYWORDS`, so the "excluding ODBC's own" rule is applied /// once for every driver instead of per backend. - fn keywords() -> &'static [&'static str] { - info::sqlite_keywords() + fn keywords(_conn: &SqliteConnection) -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(info::sqlite_keywords()) } /// Backslash: SQLite's `LIKE ... ESCAPE` takes any character, and this /// driver reports `SQL_LIKE_ESCAPE_CLAUSE = "Y"`. Backslash is the /// conventional choice and the one `SQLTables`-style pattern arguments are /// documented against. - fn search_pattern_escape() -> &'static str { - "\\" + fn search_pattern_escape(_conn: &SqliteConnection) -> Cow<'static, str> { + Cow::Borrowed("\\") } // --- Delegations --- - fn exec_direct(conn: &SqliteConnection, sql: &str) -> Result<SqliteStatement, SqliteError> { + fn exec_direct( + conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, + sql: &str, + ) -> Result<SqliteStatement, SqliteError> { execute::exec_direct(conn, sql) } - fn prepare(conn: &SqliteConnection, sql: &str) -> Result<SqliteStatement, SqliteError> { + fn prepare( + conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, + sql: &str, + ) -> Result<SqliteStatement, SqliteError> { execute::prepare(conn, sql) } fn execute( conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, stmt: &mut SqliteStatement, params: &[ColumnValue], ) -> Result<ExecuteOutcome, SqliteError> { @@ -710,52 +814,62 @@ impl Backend for SqliteBackend { info::get_info_raw(conn, info_type) } - fn get_functions() -> &'static [stackable_odbc_core::function_id::FunctionId] { - info::get_functions() + fn get_functions() -> Cow<'static, [stackable_odbc_core::function_id::FunctionId]> { + Cow::Borrowed(info::get_functions()) } - fn get_type_info() -> &'static [TypeInfoRow] { - info::get_type_info() + fn get_type_info(_conn: &SqliteConnection) -> Cow<'static, [TypeInfoRow]> { + Cow::Borrowed(info::get_type_info()) } fn tables( conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, catalog: Option<&str>, schema: Option<&str>, table: Option<&str>, table_type: Option<&str>, - ) -> Result<SqliteStatement, SqliteError> { + ) -> Result<Vec<TableRow>, SqliteError> { metadata::tables(conn, catalog, schema, table, table_type) } + /// `TABLE` and `VIEW` — the two values `metadata::tables` can put in + /// `TABLE_TYPE`. See `metadata::table_types`. + fn table_types(_conn: &SqliteConnection) -> Vec<Cow<'static, str>> { + metadata::table_types() + } + fn columns( conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, catalog: Option<&str>, schema: Option<&str>, table: Option<&str>, column: Option<&str>, - ) -> Result<SqliteStatement, SqliteError> { + ) -> Result<Vec<ColumnRow>, SqliteError> { metadata::columns(conn, catalog, schema, table, column) } fn primary_keys( conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, catalog: Option<&str>, schema: Option<&str>, table: Option<&str>, - ) -> Result<SqliteStatement, SqliteError> { + ) -> Result<Vec<PrimaryKeyRow>, SqliteError> { metadata::primary_keys(conn, catalog, schema, table) } fn foreign_keys( conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, pk_catalog: Option<&str>, pk_schema: Option<&str>, pk_table: Option<&str>, fk_catalog: Option<&str>, fk_schema: Option<&str>, fk_table: Option<&str>, - ) -> Result<SqliteStatement, SqliteError> { + ) -> Result<Vec<ForeignKeyRow>, SqliteError> { metadata::foreign_keys( conn, pk_catalog, pk_schema, pk_table, fk_catalog, fk_schema, fk_table, ) @@ -763,23 +877,25 @@ impl Backend for SqliteBackend { fn statistics( conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, catalog: Option<&str>, schema: Option<&str>, table: Option<&str>, unique_only: bool, - ) -> Result<SqliteStatement, SqliteError> { + ) -> Result<Vec<StatisticsRow>, SqliteError> { metadata::statistics(conn, catalog, schema, table, unique_only) } fn special_columns( conn: &SqliteConnection, + _cancel: &Arc<rusqlite::InterruptHandle>, identifier_type: stackable_odbc_core::types::IdentifierType, catalog: Option<&str>, schema: Option<&str>, table: Option<&str>, scope: stackable_odbc_core::types::Scope, nullable: stackable_odbc_core::types::Nullable, - ) -> Result<SqliteStatement, SqliteError> { + ) -> Result<Vec<SpecialColumnRow>, SqliteError> { metadata::special_columns( conn, identifier_type, @@ -794,7 +910,7 @@ impl Backend for SqliteBackend { /// SQLite's `{fn}`/`{d}`/`{t}`/`{ts}` escape-translation dialect. See /// `crate::escape_dialect` for the remap table and its justification /// against the `SQL_*_FUNCTIONS` bitmaps in `backend/info.rs`. - fn escape_dialect() -> stackable_odbc_core::escape::EscapeDialect { + fn escape_dialect(_conn: &SqliteConnection) -> stackable_odbc_core::escape::EscapeDialect { crate::escape_dialect::dialect() } } @@ -834,20 +950,43 @@ mod tests { } // Prepare a parameterized SELECT once. - let mut stmt = SqliteBackend::prepare(&conn, "SELECT id FROM t WHERE id = ?1").unwrap(); + let mut stmt = SqliteBackend::prepare( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT id FROM t WHERE id = ?1", + ) + .unwrap(); // First execute: matching param -> exactly one row. - SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(2)]).unwrap(); + SqliteBackend::execute( + &conn, + &SqliteBackend::cancel_token(&conn), + &mut stmt, + &[ColumnValue::I64(2)], + ) + .unwrap(); assert!(matches!(stmt.fetch().unwrap(), FetchResult::Row)); assert!(matches!(stmt.fetch().unwrap(), FetchResult::NoData)); // Re-execute the SAME handle with a non-matching param -> no rows. This // fails if the cached compiled statement leaked the previous binding. - SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(999)]).unwrap(); + SqliteBackend::execute( + &conn, + &SqliteBackend::cancel_token(&conn), + &mut stmt, + &[ColumnValue::I64(999)], + ) + .unwrap(); assert!(matches!(stmt.fetch().unwrap(), FetchResult::NoData)); // And once more with a matching param -> one row again. - SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + SqliteBackend::execute( + &conn, + &SqliteBackend::cancel_token(&conn), + &mut stmt, + &[ColumnValue::I64(1)], + ) + .unwrap(); assert!(matches!(stmt.fetch().unwrap(), FetchResult::Row)); assert!(matches!(stmt.fetch().unwrap(), FetchResult::NoData)); } @@ -863,7 +1002,8 @@ mod tests { let db = conn.conn.lock().unwrap(); db.execute_batch(setup).unwrap(); } - let Err(err) = SqliteBackend::exec_direct(&conn, sql) else { + let Err(err) = SqliteBackend::exec_direct(&conn, &SqliteBackend::cancel_token(&conn), sql) + else { panic!("statement should have failed: {sql}"); }; OdbcError::from(err).sqlstate().as_str().to_string() @@ -913,7 +1053,12 @@ mod tests { } // Manual-commit mode; the FK violation is deferred until COMMIT. SqliteBackend::set_autocommit(&conn, false).unwrap(); - SqliteBackend::exec_direct(&conn, "INSERT INTO child VALUES (999)").unwrap(); + SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "INSERT INTO child VALUES (999)", + ) + .unwrap(); let Err(err) = SqliteBackend::end_tran(&conn, true) else { panic!("COMMIT should have failed the deferred foreign-key constraint"); }; @@ -978,7 +1123,12 @@ mod tests { ) .unwrap(); } - let mut stmt = SqliteBackend::exec_direct(&conn, "SELECT id, name FROM t").unwrap(); + let mut stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT id, name FROM t", + ) + .unwrap(); assert_eq!(stmt.column_count(), 2); assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); assert_eq!( @@ -1000,7 +1150,12 @@ mod tests { let db = conn.conn.lock().unwrap(); db.execute_batch("CREATE TABLE t (id INTEGER)").unwrap(); } - let mut stmt = SqliteBackend::exec_direct(&conn, "SELECT * FROM t").unwrap(); + let mut stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT * FROM t", + ) + .unwrap(); assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); } @@ -1013,7 +1168,12 @@ mod tests { db.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES (NULL);") .unwrap(); } - let mut stmt = SqliteBackend::exec_direct(&conn, "SELECT v FROM t").unwrap(); + let mut stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT v FROM t", + ) + .unwrap(); assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); assert_eq!( stmt.get_data(1, CDataType::Default).unwrap().into_owned(), @@ -1030,11 +1190,16 @@ mod tests { db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") .unwrap(); } - let stmt = SqliteBackend::exec_direct(&conn, "SELECT id, name FROM t").unwrap(); + let stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT id, name FROM t", + ) + .unwrap(); let col1 = stmt.describe_col(1).unwrap(); - assert_eq!(col1.name, "id"); + assert_eq!(col1.name(), "id"); let col2 = stmt.describe_col(2).unwrap(); - assert_eq!(col2.name, "name"); + assert_eq!(col2.name(), "name"); } #[test] @@ -1048,7 +1213,12 @@ mod tests { ) .unwrap(); } - let stmt = SqliteBackend::exec_direct(&conn, "SELECT * FROM t").unwrap(); + let stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT * FROM t", + ) + .unwrap(); assert_eq!(stmt.row_count(), Some(2)); } @@ -1091,7 +1261,12 @@ mod tests { ) .unwrap(); } - let stmt = SqliteBackend::exec_direct(&conn, "UPDATE t SET v = 99 WHERE v = 10").unwrap(); + let stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "UPDATE t SET v = 99 WHERE v = 10", + ) + .unwrap(); assert_eq!(stmt.column_count(), 0); assert_eq!(stmt.row_count(), Some(2)); // rows 1 and 3 were updated } @@ -1110,12 +1285,22 @@ mod tests { ) .unwrap(); } - let stmt = SqliteBackend::exec_direct(&conn, "DELETE FROM t WHERE id > 1").unwrap(); + let stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "DELETE FROM t WHERE id > 1", + ) + .unwrap(); assert_eq!(stmt.column_count(), 0); assert_eq!(stmt.row_count(), Some(2)); // Confirm only row 1 remains - let mut sel = SqliteBackend::exec_direct(&conn, "SELECT COUNT(*) FROM t").unwrap(); + let mut sel = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT COUNT(*) FROM t", + ) + .unwrap(); assert_eq!(sel.fetch().unwrap(), FetchResult::Row); assert_eq!( sel.get_data(1, CDataType::Default).unwrap().into_owned(), @@ -1131,7 +1316,12 @@ mod tests { let db = conn.conn.lock().unwrap(); db.execute_batch("CREATE TABLE t (id INTEGER)").unwrap(); } - let mut stmt = SqliteBackend::exec_direct(&conn, "INSERT INTO t VALUES (1)").unwrap(); + let mut stmt = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "INSERT INTO t VALUES (1)", + ) + .unwrap(); // DML results have no rows; fetch must return NoData immediately assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); } @@ -1149,7 +1339,12 @@ mod tests { db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") .unwrap(); } - let stmt = SqliteBackend::prepare(&conn, "SELECT id FROM t WHERE id = ?").unwrap(); + let stmt = SqliteBackend::prepare( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT id FROM t WHERE id = ?", + ) + .unwrap(); assert_eq!( stmt.prepared_sql.as_deref(), Some("SELECT id FROM t WHERE id = ?") @@ -1161,7 +1356,11 @@ mod tests { fn prepare_invalid_sql_returns_error() { let params = ConnectParams::parse("Database=:memory:").unwrap(); let conn = SqliteBackend::connect(&params).unwrap(); - let result = SqliteBackend::prepare(&conn, "NOT VALID SQL %%%"); + let result = SqliteBackend::prepare( + &conn, + &SqliteBackend::cancel_token(&conn), + "NOT VALID SQL %%%", + ); assert!(result.is_err()); } @@ -1178,8 +1377,19 @@ mod tests { ) .unwrap(); } - let mut stmt = SqliteBackend::prepare(&conn, "SELECT name FROM t WHERE id = ?").unwrap(); - SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + let mut stmt = SqliteBackend::prepare( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT name FROM t WHERE id = ?", + ) + .unwrap(); + SqliteBackend::execute( + &conn, + &SqliteBackend::cancel_token(&conn), + &mut stmt, + &[ColumnValue::I64(1)], + ) + .unwrap(); assert_eq!(stmt.column_count(), 1); assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); assert_eq!( @@ -1202,10 +1412,21 @@ mod tests { ) .unwrap(); } - let mut stmt = SqliteBackend::prepare(&conn, "SELECT name FROM t WHERE id = ?").unwrap(); + let mut stmt = SqliteBackend::prepare( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT name FROM t WHERE id = ?", + ) + .unwrap(); // First execution - SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + SqliteBackend::execute( + &conn, + &SqliteBackend::cancel_token(&conn), + &mut stmt, + &[ColumnValue::I64(1)], + ) + .unwrap(); assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); assert_eq!( stmt.get_data(1, CDataType::Default).unwrap().into_owned(), @@ -1213,7 +1434,13 @@ mod tests { ); // Re-execute with different param - SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(2)]).unwrap(); + SqliteBackend::execute( + &conn, + &SqliteBackend::cancel_token(&conn), + &mut stmt, + &[ColumnValue::I64(2)], + ) + .unwrap(); assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); assert_eq!( stmt.get_data(1, CDataType::Default).unwrap().into_owned(), @@ -1230,9 +1457,15 @@ mod tests { db.execute_batch("CREATE TABLE t (id INTEGER, name TEXT)") .unwrap(); } - let mut stmt = SqliteBackend::prepare(&conn, "INSERT INTO t VALUES (?, ?)").unwrap(); + let mut stmt = SqliteBackend::prepare( + &conn, + &SqliteBackend::cancel_token(&conn), + "INSERT INTO t VALUES (?, ?)", + ) + .unwrap(); SqliteBackend::execute( &conn, + &SqliteBackend::cancel_token(&conn), &mut stmt, &[ColumnValue::I64(42), ColumnValue::String("test".into())], ) @@ -1240,7 +1473,12 @@ mod tests { assert_eq!(stmt.row_count(), Some(1)); // Verify with exec_direct - let mut q = SqliteBackend::exec_direct(&conn, "SELECT id, name FROM t").unwrap(); + let mut q = SqliteBackend::exec_direct( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT id, name FROM t", + ) + .unwrap(); assert_eq!(q.fetch().unwrap(), FetchResult::Row); assert_eq!( q.get_data(1, CDataType::Default).unwrap().into_owned(), @@ -1264,8 +1502,19 @@ mod tests { ) .unwrap(); } - let mut stmt = SqliteBackend::prepare(&conn, "SELECT name FROM t WHERE id = ?").unwrap(); - SqliteBackend::execute(&conn, &mut stmt, &[ColumnValue::I64(1)]).unwrap(); + let mut stmt = SqliteBackend::prepare( + &conn, + &SqliteBackend::cancel_token(&conn), + "SELECT name FROM t WHERE id = ?", + ) + .unwrap(); + SqliteBackend::execute( + &conn, + &SqliteBackend::cancel_token(&conn), + &mut stmt, + &[ColumnValue::I64(1)], + ) + .unwrap(); assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); assert_eq!( stmt.get_data(1, CDataType::Default).unwrap().into_owned(), diff --git a/src/backend/execute.rs b/src/backend/execute.rs index a70b58d..4fc06fa 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -119,7 +119,7 @@ pub(super) fn exec_direct( let mut row_values = Vec::with_capacity(col_count); for (i, col) in columns.iter().enumerate() { let value: rusqlite::types::Value = row.get(i).map_err(map_sqlite_error)?; - row_values.push(sqlite_value_to_column_value(value, col.sql_type)); + row_values.push(sqlite_value_to_column_value(value, col.sql_type())); } rows.push(row_values); } @@ -204,7 +204,7 @@ pub(super) fn execute( let mut row_values = Vec::with_capacity(col_count); for (i, col) in columns.iter().enumerate() { let value: rusqlite::types::Value = row.get(i).map_err(map_sqlite_error)?; - row_values.push(sqlite_value_to_column_value(value, col.sql_type)); + row_values.push(sqlite_value_to_column_value(value, col.sql_type())); } rows.push(row_values); } @@ -323,8 +323,10 @@ mod tests { fn conn_with(schema: &str) -> SqliteConnection { let c = rusqlite::Connection::open_in_memory().unwrap(); c.execute_batch(schema).unwrap(); + let interrupt = std::sync::Arc::new(c.get_interrupt_handle()); SqliteConnection { conn: Mutex::new(c), + interrupt, manual_commit: AtomicBool::new(false), } } @@ -338,8 +340,8 @@ mod tests { let mut stmt = exec_direct(&conn, "SELECT id, name FROM t ORDER BY id").unwrap(); assert_eq!(stmt.column_count(), 2); - assert_eq!(stmt.describe_col(1).unwrap().name, "id"); - assert_eq!(stmt.describe_col(2).unwrap().name, "name"); + assert_eq!(stmt.describe_col(1).unwrap().name(), "id"); + assert_eq!(stmt.describe_col(2).unwrap().name(), "name"); assert_eq!(stmt.row_count(), Some(2)); assert!(matches!(stmt.fetch().unwrap(), FetchResult::Row)); diff --git a/src/backend/info.rs b/src/backend/info.rs index 0893667..94d15b1 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -122,216 +122,224 @@ static SUPPORTED_FUNCTIONS: &[FunctionId] = &[ // then ... TYPE_NAME" requirement. This invariant is asserted directly by // `type_info_rows_sorted_by_data_type_then_type_name` below; keep new rows // in the correct sorted position rather than appending them. -static SQLITE_TYPE_INFO: &[TypeInfoRow] = &[ - // WVARCHAR — sqlite_type_to_sql_data_type maps VARCHAR/CHAR/CHARACTER/ - // NCHAR/NVARCHAR/VARYING CHARACTER/NATIVE CHARACTER/TEXT/CLOB here, and - // it is the CHAR/CLOB/TEXT-affinity fallback too. This is the row that - // actually satisfies the invariant for every text-affinity declared - // type; the SQL_VARCHAR/SQL_CHAR rows further down this list - // exist only for Windows DM/pyodbc ANSI compatibility. - TypeInfoRow::new("WVARCHAR", SqlDataType::EXT_W_VARCHAR) - .with_column_size(catalog_column_size( - SqlDataType::EXT_W_VARCHAR, - MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), - MaxScale(0), - )) - .with_literal_affixes(Some("'"), Some("'")) - .with_create_params(Some("max length")) - .with_case_sensitive(true), - // WCHAR — Unicode counterpart to the CHAR row further down this list, - // included for symmetry per the Windows DM checklist even though - // sqlite_type_to_sql_data_type itself never produces EXT_W_CHAR (declared - // CHAR(n) collapses into the WVARCHAR affinity above, matching real - // SQLite semantics where CHAR(n) is not length-limited). - TypeInfoRow::new("WCHAR", SqlDataType::EXT_W_CHAR) - .with_column_size(catalog_column_size( - SqlDataType::EXT_W_CHAR, - MaxPrecision(WCHAR_COLUMN_SIZE_ROW), - MaxScale(0), - )) - .with_literal_affixes(Some("'"), Some("'")) - .with_create_params(Some("length")) - .with_case_sensitive(true), - // BIT — sqlite_type_to_sql_data_type maps BOOLEAN/BOOL here. - TypeInfoRow::new("BIT", SqlDataType::EXT_BIT).with_column_size(catalog_column_size( - SqlDataType::EXT_BIT, - MaxPrecision(0), - MaxScale(0), - )), - // TINYINT — sqlite_type_to_sql_data_type maps TINYINT here. - TypeInfoRow::new("TINYINT", SqlDataType::EXT_TINY_INT) - .with_column_size(catalog_column_size( - SqlDataType::EXT_TINY_INT, - MaxPrecision(0), - MaxScale(0), - )) - .with_unsigned(Some(false)) - .with_auto_unique_value(Some(false)) - .with_scale_range(Some(0), Some(0)) - .with_num_prec_radix(Some(10)), - // BIGINT — sqlite_type_to_sql_data_type maps INTEGER/INT/BIGINT/INT8 here - // (and the "INT"-substring affinity fallback), since SQLite integers are - // always 64-bit storage. This is the row an INTEGER column's reported - // type (SQL_BIGINT) actually resolves to. - TypeInfoRow::new("BIGINT", SqlDataType::EXT_BIG_INT) - .with_column_size(catalog_column_size( - SqlDataType::EXT_BIG_INT, - MaxPrecision(0), - MaxScale(0), - )) - .with_unsigned(Some(false)) - .with_auto_unique_value(Some(false)) - .with_scale_range(Some(0), Some(0)) - .with_num_prec_radix(Some(10)), - TypeInfoRow::new("BLOB", SqlDataType::EXT_VAR_BINARY) - .with_column_size(catalog_column_size( - SqlDataType::EXT_VAR_BINARY, - MaxPrecision(BLOB_DEFAULT_COLUMN_SIZE), - MaxScale(0), - )) - .with_literal_affixes(Some("X'"), Some("'")) - .with_create_params(Some("max length")), - // SQL_CHAR (1) — ANSI alias. See the SQL_VARCHAR comment further down - // this list; same rationale for why this is a distinct row from the - // WCHAR row above. - TypeInfoRow::new("CHAR", SqlDataType::CHAR) - .with_column_size(catalog_column_size( - SqlDataType::CHAR, - MaxPrecision(CHAR_COLUMN_SIZE_ROW), - MaxScale(0), - )) - .with_literal_affixes(Some("'"), Some("'")) - .with_create_params(Some("length")) - .with_case_sensitive(true), - // DECIMAL — sqlite_type_to_sql_data_type maps DECIMAL/NUMERIC here, and - // it is also the NUMERIC-affinity fallback for any declared type that - // SQLite's own affinity rules do not otherwise classify. - TypeInfoRow::new("DECIMAL", SqlDataType::DECIMAL) - .with_column_size(catalog_column_size( - SqlDataType::DECIMAL, - MaxPrecision(DECIMAL_DEFAULT_COLUMN_SIZE), - MaxScale(DECIMAL_MAX_SCALE), - )) - .with_create_params(Some("precision,scale")) - .with_unsigned(Some(false)) - .with_auto_unique_value(Some(false)) - .with_scale_range(Some(0), Some(DECIMAL_MAX_SCALE)) - .with_num_prec_radix(Some(10)), - TypeInfoRow::new("INTEGER", SqlDataType::INTEGER) - .with_column_size(catalog_column_size( - SqlDataType::INTEGER, - MaxPrecision(0), - MaxScale(0), - )) - .with_unsigned(Some(false)) - .with_auto_unique_value(Some(false)) - .with_scale_range(Some(0), Some(0)) - .with_num_prec_radix(Some(10)), - // SMALLINT — sqlite_type_to_sql_data_type maps SMALLINT/INT2 here. - TypeInfoRow::new("SMALLINT", SqlDataType::SMALLINT) - .with_column_size(catalog_column_size( - SqlDataType::SMALLINT, - MaxPrecision(0), - MaxScale(0), - )) - .with_unsigned(Some(false)) - .with_auto_unique_value(Some(false)) - .with_scale_range(Some(0), Some(0)) - .with_num_prec_radix(Some(10)), - TypeInfoRow::new("REAL", SqlDataType::DOUBLE) - .with_column_size(catalog_column_size( - SqlDataType::DOUBLE, - MaxPrecision(0), - MaxScale(0), - )) - .with_unsigned(Some(false)) - .with_num_prec_radix(Some(2)), - // TEXT — column_size matches VARCHAR_DEFAULT_COLUMN_SIZE (255), the - // same default `default_precision_for_type` reports for both VARCHAR and - // EXT_W_VARCHAR (see type_conversion.rs). This row and the VARCHAR row - // immediately below both describe SQLite's single, unbounded TEXT - // storage class under the shared ANSI DATA_TYPE=12, so they must report - // the same size. 255 is the value the rest of the driver treats as - // authoritative for this DATA_TYPE (`default_precision_for_type`, and the - // WVARCHAR row below), so both rows use it. - TypeInfoRow::new("TEXT", SqlDataType::VARCHAR) - .with_column_size(catalog_column_size( - SqlDataType::VARCHAR, - MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), - MaxScale(0), - )) - .with_literal_affixes(Some("'"), Some("'")) - .with_create_params(Some("max length")) - .with_case_sensitive(true), - // SQL_VARCHAR (12) — ANSI alias needed for Windows DM / pyodbc type - // conversion (AGENTS.md "Windows Driver Manager compatibility - // checklist"). sqlite_type_to_sql_data_type never actually returns this - // ANSI code (only EXT_W_VARCHAR, see the WVARCHAR row above); this row - // exists purely so SQLGetTypeInfo(SQL_VARCHAR) finds a match. TYPE_NAME - // differs from the TEXT row immediately above (same DATA_TYPE) because - // SQLite itself treats VARCHAR as a recognised alias of TEXT, and the - // spec explicitly allows multiple rows sharing a DATA_TYPE; column_size - // matches the TEXT row above for the same reason (see that row's - // comment). - TypeInfoRow::new("VARCHAR", SqlDataType::VARCHAR) - .with_column_size(catalog_column_size( - SqlDataType::VARCHAR, - MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), - MaxScale(0), - )) - .with_literal_affixes(Some("'"), Some("'")) - .with_create_params(Some("max length")) - .with_case_sensitive(true), - // DATE — sqlite_type_to_sql_data_type maps DATE here. SQLite has no DATE - // literal syntax; a date value is just a quoted ISO-8601 string, hence - // the plain quote prefix/suffix (matching the TEXT row's convention) - // rather than a typed `DATE '...'` literal. - // DATA_TYPE=91 (SQL_TYPE_DATE), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=1 (SQL_CODE_DATE) - TypeInfoRow::new("DATE", SqlDataType::DATE) - .with_column_size(catalog_column_size( - SqlDataType::DATE, +// +// A `LazyLock` rather than a plain `static`: `TypeInfoRow`'s string fields are +// `Cow<'static, str>` so a backend can compute them, and converting a `&'static +// str` literal through `Into` is not a const operation — `TypeInfoRow::new` and +// the three string builders are therefore not `const fn`. The table is fixed at +// compile time, so it is built once and borrowed for the life of the process. +static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::LazyLock::new(|| { + vec![ + // WVARCHAR — sqlite_type_to_sql_data_type maps VARCHAR/CHAR/CHARACTER/ + // NCHAR/NVARCHAR/VARYING CHARACTER/NATIVE CHARACTER/TEXT/CLOB here, and + // it is the CHAR/CLOB/TEXT-affinity fallback too. This is the row that + // actually satisfies the invariant for every text-affinity declared + // type; the SQL_VARCHAR/SQL_CHAR rows further down this list + // exist only for Windows DM/pyodbc ANSI compatibility. + TypeInfoRow::new("WVARCHAR", SqlDataType::EXT_W_VARCHAR) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_VARCHAR, + MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), + // WCHAR — Unicode counterpart to the CHAR row further down this list, + // included for symmetry per the Windows DM checklist even though + // sqlite_type_to_sql_data_type itself never produces EXT_W_CHAR (declared + // CHAR(n) collapses into the WVARCHAR affinity above, matching real + // SQLite semantics where CHAR(n) is not length-limited). + TypeInfoRow::new("WCHAR", SqlDataType::EXT_W_CHAR) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_CHAR, + MaxPrecision(WCHAR_COLUMN_SIZE_ROW), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("length")) + .with_case_sensitive(true), + // BIT — sqlite_type_to_sql_data_type maps BOOLEAN/BOOL here. + TypeInfoRow::new("BIT", SqlDataType::EXT_BIT).with_column_size(catalog_column_size( + SqlDataType::EXT_BIT, MaxPrecision(0), MaxScale(0), - )) - // 'YYYY-MM-DD' - .with_literal_affixes(Some("'"), Some("'")) - .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_DATE)), - // TIME — sqlite_type_to_sql_data_type maps TIME here. SQLite stores time - // values as plain "HH:MM:SS" text with no fractional-seconds field (see - // column_value_to_rusqlite), so scale is fixed at 0. - // DATA_TYPE=92 (SQL_TYPE_TIME), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=2 (SQL_CODE_TIME) - TypeInfoRow::new("TIME", SqlDataType::TIME) - // 'HH:MM:SS': SQLite has no fractional-seconds capability to report - // as a maximum (MAX_FRACTIONAL_SECONDS_PRECISION = 0), so this is - // the plain (scale-0) form of the TIME formula. - .with_column_size(catalog_column_size( - SqlDataType::TIME, - MaxPrecision(0), - MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), - )) - .with_literal_affixes(Some("'"), Some("'")) - .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) - .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIME)), - // TIMESTAMP — sqlite_type_to_sql_data_type maps DATETIME/TIMESTAMP here. - // column_size intentionally excludes a fractional-seconds allowance: it - // is computed via catalog_column_size at MAX_FRACTIONAL_SECONDS_PRECISION - // (0), the same constant sqlite_declared_type_precision uses as the - // fallback for an undeclared TIMESTAMP column (see the consistency test - // below), so minimum/maximum scale are reported as fixed at 0 rather - // than claiming precision the column size does not budget for. - // DATA_TYPE=93 (SQL_TYPE_TIMESTAMP), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=3 (SQL_CODE_TIMESTAMP) - TypeInfoRow::new("TIMESTAMP", SqlDataType::TIMESTAMP) - // 'YYYY-MM-DD HH:MM:SS': same no-fractional-capability rationale - // as the TIME row above. - .with_column_size(catalog_column_size( - SqlDataType::TIMESTAMP, - MaxPrecision(0), - MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), - )) - .with_literal_affixes(Some("'"), Some("'")) - .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) - .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIMESTAMP)), -]; + )), + // TINYINT — sqlite_type_to_sql_data_type maps TINYINT here. + TypeInfoRow::new("TINYINT", SqlDataType::EXT_TINY_INT) + .with_column_size(catalog_column_size( + SqlDataType::EXT_TINY_INT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + // BIGINT — sqlite_type_to_sql_data_type maps INTEGER/INT/BIGINT/INT8 here + // (and the "INT"-substring affinity fallback), since SQLite integers are + // always 64-bit storage. This is the row an INTEGER column's reported + // type (SQL_BIGINT) actually resolves to. + TypeInfoRow::new("BIGINT", SqlDataType::EXT_BIG_INT) + .with_column_size(catalog_column_size( + SqlDataType::EXT_BIG_INT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new("BLOB", SqlDataType::EXT_VAR_BINARY) + .with_column_size(catalog_column_size( + SqlDataType::EXT_VAR_BINARY, + MaxPrecision(BLOB_DEFAULT_COLUMN_SIZE), + MaxScale(0), + )) + .with_literal_affixes(Some("X'"), Some("'")) + .with_create_params(Some("max length")), + // SQL_CHAR (1) — ANSI alias. See the SQL_VARCHAR comment further down + // this list; same rationale for why this is a distinct row from the + // WCHAR row above. + TypeInfoRow::new("CHAR", SqlDataType::CHAR) + .with_column_size(catalog_column_size( + SqlDataType::CHAR, + MaxPrecision(CHAR_COLUMN_SIZE_ROW), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("length")) + .with_case_sensitive(true), + // DECIMAL — sqlite_type_to_sql_data_type maps DECIMAL/NUMERIC here, and + // it is also the NUMERIC-affinity fallback for any declared type that + // SQLite's own affinity rules do not otherwise classify. + TypeInfoRow::new("DECIMAL", SqlDataType::DECIMAL) + .with_column_size(catalog_column_size( + SqlDataType::DECIMAL, + MaxPrecision(DECIMAL_DEFAULT_COLUMN_SIZE), + MaxScale(DECIMAL_MAX_SCALE), + )) + .with_create_params(Some("precision,scale")) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(DECIMAL_MAX_SCALE)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new("INTEGER", SqlDataType::INTEGER) + .with_column_size(catalog_column_size( + SqlDataType::INTEGER, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + // SMALLINT — sqlite_type_to_sql_data_type maps SMALLINT/INT2 here. + TypeInfoRow::new("SMALLINT", SqlDataType::SMALLINT) + .with_column_size(catalog_column_size( + SqlDataType::SMALLINT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new("REAL", SqlDataType::DOUBLE) + .with_column_size(catalog_column_size( + SqlDataType::DOUBLE, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_num_prec_radix(Some(2)), + // TEXT — column_size matches VARCHAR_DEFAULT_COLUMN_SIZE (255), the + // same default `default_precision_for_type` reports for both VARCHAR and + // EXT_W_VARCHAR (see type_conversion.rs). This row and the VARCHAR row + // immediately below both describe SQLite's single, unbounded TEXT + // storage class under the shared ANSI DATA_TYPE=12, so they must report + // the same size. 255 is the value the rest of the driver treats as + // authoritative for this DATA_TYPE (`default_precision_for_type`, and the + // WVARCHAR row below), so both rows use it. + TypeInfoRow::new("TEXT", SqlDataType::VARCHAR) + .with_column_size(catalog_column_size( + SqlDataType::VARCHAR, + MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), + // SQL_VARCHAR (12) — ANSI alias needed for Windows DM / pyodbc type + // conversion (AGENTS.md "Windows Driver Manager compatibility + // checklist"). sqlite_type_to_sql_data_type never actually returns this + // ANSI code (only EXT_W_VARCHAR, see the WVARCHAR row above); this row + // exists purely so SQLGetTypeInfo(SQL_VARCHAR) finds a match. TYPE_NAME + // differs from the TEXT row immediately above (same DATA_TYPE) because + // SQLite itself treats VARCHAR as a recognised alias of TEXT, and the + // spec explicitly allows multiple rows sharing a DATA_TYPE; column_size + // matches the TEXT row above for the same reason (see that row's + // comment). + TypeInfoRow::new("VARCHAR", SqlDataType::VARCHAR) + .with_column_size(catalog_column_size( + SqlDataType::VARCHAR, + MaxPrecision(VARCHAR_DEFAULT_COLUMN_SIZE), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), + // DATE — sqlite_type_to_sql_data_type maps DATE here. SQLite has no DATE + // literal syntax; a date value is just a quoted ISO-8601 string, hence + // the plain quote prefix/suffix (matching the TEXT row's convention) + // rather than a typed `DATE '...'` literal. + // DATA_TYPE=91 (SQL_TYPE_DATE), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=1 (SQL_CODE_DATE) + TypeInfoRow::new("DATE", SqlDataType::DATE) + .with_column_size(catalog_column_size( + SqlDataType::DATE, + MaxPrecision(0), + MaxScale(0), + )) + // 'YYYY-MM-DD' + .with_literal_affixes(Some("'"), Some("'")) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_DATE)), + // TIME — sqlite_type_to_sql_data_type maps TIME here. SQLite stores time + // values as plain "HH:MM:SS" text with no fractional-seconds field (see + // column_value_to_rusqlite), so scale is fixed at 0. + // DATA_TYPE=92 (SQL_TYPE_TIME), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=2 (SQL_CODE_TIME) + TypeInfoRow::new("TIME", SqlDataType::TIME) + // 'HH:MM:SS': SQLite has no fractional-seconds capability to report + // as a maximum (MAX_FRACTIONAL_SECONDS_PRECISION = 0), so this is + // the plain (scale-0) form of the TIME formula. + .with_column_size(catalog_column_size( + SqlDataType::TIME, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIME)), + // TIMESTAMP — sqlite_type_to_sql_data_type maps DATETIME/TIMESTAMP here. + // column_size intentionally excludes a fractional-seconds allowance: it + // is computed via catalog_column_size at MAX_FRACTIONAL_SECONDS_PRECISION + // (0), the same constant sqlite_declared_type_precision uses as the + // fallback for an undeclared TIMESTAMP column (see the consistency test + // below), so minimum/maximum scale are reported as fixed at 0 rather + // than claiming precision the column size does not budget for. + // DATA_TYPE=93 (SQL_TYPE_TIMESTAMP), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=3 (SQL_CODE_TIMESTAMP) + TypeInfoRow::new("TIMESTAMP", SqlDataType::TIMESTAMP) + // 'YYYY-MM-DD HH:MM:SS': same no-fractional-capability rationale + // as the TIME row above. + .with_column_size(catalog_column_size( + SqlDataType::TIMESTAMP, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIMESTAMP)), + ] +}); // `CHAR`/`WCHAR`'s "unbounded" sentinel. `VARCHAR`/`DECIMAL`/`BLOB`'s default // column sizes and `TIME`/`TIMESTAMP`'s maximum fractional-seconds precision @@ -345,10 +353,18 @@ const WCHAR_COLUMN_SIZE_ROW: i32 = u16::MAX as i32; /// so precision and scale share the same conventional ceiling. const DECIMAL_MAX_SCALE: i16 = 38; -/// All values here are connection-independent (driver-level constants). +/// The arms of this match are connection-independent (driver-level constants). /// Extracted so that both the connected and pre-connect paths can use it /// without duplicating the match. -fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { +/// +/// `conn` is `None` on the pre-connect path, and is carried only to be handed +/// on: core's capability hooks and `default_get_info` take +/// `Option<&Self::Connection>` since `SQLGetInfo` is a per-connection call. +/// Pre-connect they answer only what is knowable without a data source. +fn sqlite_get_info( + conn: Option<&SqliteConnection>, + info_type: InfoType, +) -> Result<InfoValue, SqliteError> { // Driver-specific overrides match info_type { InfoType::DriverName => return Ok(InfoValue::String("stackable-odbc-sqlite".into())), @@ -380,10 +396,18 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { // states a bound on something it has just said does not exist. The // spec defines 0 as "no maximum length or the length is unknown", // which is the closest available reading of "not applicable". - InfoType::MaxCatalogNameLen if !SqliteBackend::supports_catalogs() => { + // + // `supports_catalogs`/`supports_schemas` are per-connection hooks, so + // these arms only apply once a connection exists. Pre-connect the + // question falls through to core, which answers its generic identifier + // length -- the same shape it reports for every other `SQL_MAX_*_LEN` + // before a data source is open. + InfoType::MaxCatalogNameLen + if conn.is_some_and(|c| !SqliteBackend::supports_catalogs(c)) => + { return Ok(InfoValue::U16(0)); } - InfoType::MaxSchemaNameLen if !SqliteBackend::supports_schemas() => { + InfoType::MaxSchemaNameLen if conn.is_some_and(|c| !SqliteBackend::supports_schemas(c)) => { return Ok(InfoValue::U16(0)); } // "Y": SQLite implements the whole Integrity Enhancement Facility -- @@ -446,7 +470,7 @@ fn sqlite_get_info(info_type: InfoType) -> Result<InfoValue, SqliteError> { // Fall through to shared defaults. Core reads the catalog result column // widths off the backend type parameter itself, so they cannot disagree // with what this driver reports everywhere else. - default_get_info::<SqliteBackend>(info_type).ok_or_else(|| SqliteError::NotImplemented { + default_get_info::<SqliteBackend>(conn, info_type).ok_or_else(|| SqliteError::NotImplemented { feature: format!("get_info({info_type:?})"), }) } @@ -458,7 +482,7 @@ pub(super) fn get_info( if let Some(value) = connection_limit(conn, info_type)? { return Ok(value); } - sqlite_get_info(info_type) + sqlite_get_info(Some(conn), info_type) } /// The `SQL_MAX_*` values SQLite can be asked for directly, via @@ -528,7 +552,7 @@ fn connection_limit( } pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, SqliteError> { - sqlite_get_info(info_type) + sqlite_get_info(None, info_type) } /// `SQL_AGGREGATE_FUNCTIONS` — SQLite has every ODBC aggregate, and accepts @@ -766,12 +790,14 @@ pub(crate) const SQLITE_TIMEDATE_FUNCTIONS: u32 = SQL_FN_TD_NOW /// call — it cannot cache a value that is generic over the backend — and /// walking SQLite's keyword table each time would be wasteful. The table is /// fixed at link time, so one walk is enough. -pub(crate) fn sqlite_keywords() -> &'static [&'static str] { - static KEYWORDS: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new(); +pub(crate) fn sqlite_keywords() -> &'static [std::borrow::Cow<'static, str>] { + static KEYWORDS: std::sync::OnceLock<Vec<std::borrow::Cow<'static, str>>> = + std::sync::OnceLock::new(); KEYWORDS .get_or_init(|| { let count = unsafe { rusqlite::ffi::sqlite3_keyword_count() }; - let mut names: Vec<&'static str> = Vec::with_capacity(count.max(0) as usize); + let mut names: Vec<std::borrow::Cow<'static, str>> = + Vec::with_capacity(count.max(0) as usize); for i in 0..count { let mut ptr: *const std::ffi::c_char = std::ptr::null(); @@ -789,7 +815,7 @@ pub(crate) fn sqlite_keywords() -> &'static [&'static str] { // keyword that SQLite never mutates or frees. let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }; if let Ok(name) = std::str::from_utf8(bytes) { - names.push(name); + names.push(std::borrow::Cow::Borrowed(name)); } } @@ -799,7 +825,7 @@ pub(crate) fn sqlite_keywords() -> &'static [&'static str] { } pub(super) fn get_info_raw( - _conn: &SqliteConnection, + conn: &SqliteConnection, info_type: u16, ) -> Option<Result<InfoValue, SqliteError>> { // Capability info types. Each one is a genuine `odbc_sys::InfoType` @@ -845,7 +871,7 @@ pub(super) fn get_info_raw( // since 3.39.0; this build is 3.53.2). SQL_LIKE_ESCAPE_CLAUSE => Some(Ok(InfoValue::String("Y".into()))), SQL_OUTER_JOINS => Some(Ok(InfoValue::String("Y".into()))), - _ => common_get_info_raw::<SqliteBackend>(info_type).map(Ok), + _ => common_get_info_raw::<SqliteBackend>(Some(conn), info_type).map(Ok), } } @@ -864,7 +890,7 @@ pub(super) fn get_functions() -> &'static [FunctionId] { } pub(super) fn get_type_info() -> &'static [TypeInfoRow] { - SQLITE_TYPE_INFO + &SQLITE_TYPE_INFO } /// Bare, uppercase data-source-dependent type name for a column of @@ -922,8 +948,8 @@ pub(super) fn sqlite_bare_type_name(sql_type: SqlDataType) -> &'static str { } SQLITE_TYPE_INFO .iter() - .find(|row| row.data_type == sql_type) - .map(|row| row.type_name) + .find(|row| row.data_type() == sql_type) + .map(|row| row.type_name()) .unwrap_or_else(|| { tracing::warn!( ?sql_type, @@ -940,7 +966,7 @@ mod tests { /// Fixed-size types: the "Column Size" appendix formula for these takes /// no backend-specific parameter, so the row's value must equal the /// formula applied to *the row's own* `data_type`. Deriving the expected - /// value from `row.data_type` rather than repeating the table's own + /// value from `row.data_type()` rather than repeating the table's own /// arguments is what makes this catch a row built with the wrong /// `SqlDataType`, the one way two drivers could disagree on a value the /// spec defines as backend-independent. @@ -968,18 +994,22 @@ mod tests { SqlDataType::DATE, ]; - for row in SQLITE_TYPE_INFO { - if !BACKEND_INDEPENDENT.contains(&row.data_type) { + for row in SQLITE_TYPE_INFO.iter() { + if !BACKEND_INDEPENDENT.contains(&row.data_type()) { continue; } - let expected = catalog_column_size(row.data_type, IGNORED_PRECISION, IGNORED_SCALE); + let expected = catalog_column_size(row.data_type(), IGNORED_PRECISION, IGNORED_SCALE); assert_eq!( - row.column_size, expected, + row.column_size(), + expected, "{} (DATA_TYPE {:?}): COLUMN_SIZE is {} but the \ backend-independent appendix formula for that DATA_TYPE \ gives {} — the row is built from a different SqlDataType \ than it reports", - row.type_name, row.data_type, row.column_size, expected + row.type_name(), + row.data_type(), + row.column_size(), + expected ); } } @@ -1013,6 +1043,20 @@ mod tests { U32(u32), } + /// An open connection for the tests that reach a per-connection capability + /// hook. + /// + /// The hooks take a connection because `SQLGetInfo` is a per-connection + /// call and a data source's capabilities can differ by server. Every one + /// this driver declares is a property of the linked SQLite library rather + /// than of the file opened, so any connection answers the same — but the + /// answers must still be read through one, which is what an application + /// has. + fn test_connection() -> SqliteConnection { + let params = ConnectParams::parse("Database=:memory:").expect("parse"); + SqliteBackend::connect(&params).expect("connect") + } + #[rustfmt::skip] const EXPECTED: &[(InfoType, Expected)] = &[ // --- String values --- @@ -1127,8 +1171,9 @@ mod tests { #[test] fn get_info_snapshot() { + let conn = test_connection(); for (info_type, expected) in EXPECTED { - let actual = sqlite_get_info(*info_type) + let actual = sqlite_get_info(Some(&conn), *info_type) .unwrap_or_else(|e| panic!("get_info returned error for {info_type:?}: {e:?}")); match (expected, &actual) { (Expected::Str(s), InfoValue::String(v)) => { @@ -1147,7 +1192,7 @@ mod tests { #[test] fn dbms_ver_is_well_formed() { - let InfoValue::String(s) = sqlite_get_info(InfoType::DbmsVer).unwrap() else { + let InfoValue::String(s) = sqlite_get_info(None, InfoType::DbmsVer).unwrap() else { panic!("expected String for DbmsVer"); }; let prefix = s.split(' ').next().unwrap_or(""); @@ -1172,7 +1217,7 @@ mod tests { /// Assert the spec's shape instead. #[test] fn driver_ver_is_well_formed() { - let InfoValue::String(v) = sqlite_get_info(InfoType::DriverVer).unwrap() else { + let InfoValue::String(v) = sqlite_get_info(None, InfoType::DriverVer).unwrap() else { panic!("expected String for DriverVer"); }; let parts: Vec<&str> = v.split('.').collect(); @@ -1352,10 +1397,11 @@ mod tests { /// pinned to 0, so it stays right if either ever flips. #[test] fn catalog_and_schema_name_lengths_follow_their_support_hooks() { - let max_catalog = sqlite_get_info(InfoType::MaxCatalogNameLen).expect("info"); - let max_schema = sqlite_get_info(InfoType::MaxSchemaNameLen).expect("info"); + let conn = test_connection(); + let max_catalog = sqlite_get_info(Some(&conn), InfoType::MaxCatalogNameLen).expect("info"); + let max_schema = sqlite_get_info(Some(&conn), InfoType::MaxSchemaNameLen).expect("info"); - if SqliteBackend::supports_catalogs() { + if SqliteBackend::supports_catalogs(&conn) { assert_ne!(max_catalog, InfoValue::U16(0)); } else { assert_eq!( @@ -1364,7 +1410,7 @@ mod tests { "SQL_MAX_CATALOG_NAME_LEN bounds a name that cannot exist" ); } - if SqliteBackend::supports_schemas() { + if SqliteBackend::supports_schemas(&conn) { assert_ne!(max_schema, InfoValue::U16(0)); } else { assert_eq!( @@ -1443,7 +1489,8 @@ mod tests { /// would turn that into a failure. #[test] fn keywords_hook_feeds_sql_keywords_with_odbc_words_removed() { - let raw = SqliteBackend::keywords(); + let hook_conn = test_connection(); + let raw = SqliteBackend::keywords(&hook_conn); assert!(!raw.is_empty(), "SQLite reserves words of its own"); // Raw means unfiltered: ODBC's words are still in here, because @@ -1512,9 +1559,10 @@ mod tests { conn.prepare("SELECT count(*) FROM gb GROUP BY b") .expect("SQLite accepts a GROUP BY column absent from the select list"); - assert_eq!(SqliteBackend::group_by(), SQL_GB_NO_RELATION); + let hook_conn = test_connection(); + assert_eq!(SqliteBackend::group_by(&hook_conn), SQL_GB_NO_RELATION); assert_eq!( - SqliteBackend::sql_conformance(), + SqliteBackend::sql_conformance(&hook_conn), 0, "SQL_GB_NO_RELATION rules out the SQL-92 entry level" ); @@ -1532,7 +1580,8 @@ mod tests { /// [`SqliteBackend::supports_schemas`] ever flips. #[test] fn catalog_and_schema_info_types_agree_with_each_other() { - let get = |t: InfoType| sqlite_get_info(t).expect("info type answered"); + let conn = test_connection(); + let get = |t: InfoType| sqlite_get_info(Some(&conn), t).expect("info type answered"); let catalogs_supported = matches!( get(InfoType::CatalogName), @@ -1540,7 +1589,7 @@ mod tests { ); assert_eq!( catalogs_supported, - SqliteBackend::supports_catalogs(), + SqliteBackend::supports_catalogs(&conn), "SQL_CATALOG_NAME must follow Backend::supports_catalogs" ); @@ -1574,7 +1623,7 @@ mod tests { ); } - if SqliteBackend::supports_schemas() { + if SqliteBackend::supports_schemas(&conn) { assert_ne!(get(InfoType::SchemaTerm), InfoValue::String(String::new())); } else { assert_eq!( @@ -1606,11 +1655,12 @@ mod tests { /// delivers. #[test] fn transaction_isolation_offers_only_the_level_sqlite_implements() { - let supported = match sqlite_get_info(InfoType::TransactionIsolationProtocol) { + let conn = test_connection(); + let supported = match sqlite_get_info(Some(&conn), InfoType::TransactionIsolationProtocol) { Ok(InfoValue::U32(v)) => v, other => panic!("unexpected shape: {other:?}"), }; - let default = match sqlite_get_info(InfoType::DefaultTxnIsolation) { + let default = match sqlite_get_info(Some(&conn), InfoType::DefaultTxnIsolation) { Ok(InfoValue::U32(v)) => v, other => panic!("unexpected shape: {other:?}"), }; @@ -1885,7 +1935,9 @@ mod tests { sqlite_type_to_sql_data_type actually returns for it" ); assert!( - SQLITE_TYPE_INFO.iter().any(|row| row.data_type == reported), + SQLITE_TYPE_INFO + .iter() + .any(|row| row.data_type() == reported), "declared type {decl:?} is reported as {reported:?}, \ which has no SQLGetTypeInfo row" ); @@ -1906,7 +1958,9 @@ mod tests { ] { let reported = crate::type_conversion::sqlite_type_to_sql_data_type(decl); assert!( - SQLITE_TYPE_INFO.iter().any(|row| row.data_type == reported), + SQLITE_TYPE_INFO + .iter() + .any(|row| row.data_type() == reported), "declared type {decl:?} is reported as {reported:?}, \ which has no SQLGetTypeInfo row" ); @@ -1940,7 +1994,7 @@ mod tests { assert!( SQLITE_TYPE_INFO .iter() - .any(|row| row.type_name == name && row.data_type == sql_type), + .any(|row| row.type_name() == name && row.data_type() == sql_type), "sqlite_bare_type_name({sql_type:?}) (for declared type {decl:?}) returned \ {name:?}, which is not a matching SQLGetTypeInfo row" ); @@ -1971,17 +2025,19 @@ mod tests { // below), so neither name is ever produced by the function. const DM_COMPAT_ONLY: &[&str] = &["TEXT", "VARCHAR"]; - for row in SQLITE_TYPE_INFO { - if DM_COMPAT_ONLY.contains(&row.type_name) { + for row in SQLITE_TYPE_INFO.iter() { + if DM_COMPAT_ONLY.contains(&row.type_name()) { continue; } - let produced = sqlite_bare_type_name(row.data_type); + let produced = sqlite_bare_type_name(row.data_type()); assert_eq!( - produced, row.type_name, + produced, + row.type_name(), "SQLITE_TYPE_INFO row {:?} (DATA_TYPE={:?}) is not reachable via \ sqlite_bare_type_name (got {produced:?} instead) — no real column can \ ever be reported under this TYPE_NAME", - row.type_name, row.data_type + row.type_name(), + row.data_type() ); } } @@ -1989,11 +2045,11 @@ mod tests { #[test] fn type_info_rows_have_unique_data_types_per_name() { let mut seen = std::collections::HashSet::new(); - for row in SQLITE_TYPE_INFO { + for row in SQLITE_TYPE_INFO.iter() { assert!( - seen.insert(row.type_name), + seen.insert(row.type_name()), "duplicate type_name in SQLITE_TYPE_INFO: {}", - row.type_name + row.type_name() ); } } @@ -2040,13 +2096,14 @@ mod tests { .unwrap_or(i32::MAX); let row = SQLITE_TYPE_INFO .iter() - .find(|row| row.data_type == sql_type) + .find(|row| row.data_type() == sql_type) .unwrap_or_else(|| panic!("no SQLGetTypeInfo row for {sql_type:?} ({decl:?})")); assert_eq!( - row.column_size, expected, + row.column_size(), + expected, "column_size for {decl:?} ({sql_type:?}) row {:?} does not match \ default_precision_for_type", - row.type_name + row.type_name() ); } } @@ -2086,22 +2143,22 @@ mod tests { for pair in SQLITE_TYPE_INFO.windows(2) { let (prev, next) = (&pair[0], &pair[1]); assert!( - prev.data_type.0 <= next.data_type.0, + prev.data_type().0 <= next.data_type().0, "SQLITE_TYPE_INFO not sorted by DATA_TYPE: {:?} (DATA_TYPE={}) \ appears before {:?} (DATA_TYPE={})", - prev.type_name, - prev.data_type.0, - next.type_name, - next.data_type.0 + prev.type_name(), + prev.data_type().0, + next.type_name(), + next.data_type().0 ); - if prev.data_type == next.data_type { + if prev.data_type() == next.data_type() { assert!( - prev.type_name <= next.type_name, + prev.type_name() <= next.type_name(), "rows sharing DATA_TYPE={} not sorted by TYPE_NAME: {:?} appears \ before {:?}", - prev.data_type.0, - prev.type_name, - next.type_name + prev.data_type().0, + prev.type_name(), + next.type_name() ); } } diff --git a/src/backend/metadata.rs b/src/backend/metadata.rs index 231cbfe..2ebc325 100644 --- a/src/backend/metadata.rs +++ b/src/backend/metadata.rs @@ -3,12 +3,11 @@ //! from SQLite's `PRAGMA` introspection and `sqlite_master`, plus the private //! query helpers those functions share. -use stackable_odbc_core::backend::Backend; use stackable_odbc_core::types::{ - ColumnDescriptor, ColumnValue, ColumnsResultCol, ForeignKeysResultCol, IdentifierType, - Nullable, PrimaryKeysResultCol, SQL_CASCADE, SQL_INDEX_OTHER, SQL_NO_ACTION, SQL_PC_NOT_PSEUDO, - SQL_PC_PSEUDO, SQL_RESTRICT, SQL_SET_DEFAULT, SQL_SET_NULL, SQL_TABLE_STAT, Scope, SqlDataType, - TablesResultCol, special_columns_columns, statistics_columns, + ColumnRow, ForeignKeyRow, IdentifierType, Nullable, PrimaryKeyRow, SQL_CASCADE, + SQL_INDEX_OTHER, SQL_NO_ACTION, SQL_PC_NOT_PSEUDO, SQL_PC_PSEUDO, SQL_RESTRICT, + SQL_SET_DEFAULT, SQL_SET_NULL, SQL_TABLE_STAT, Scope, SpecialColumnRow, SqlDataType, + StatisticsRow, TableRow, }; /// Column indices for `PRAGMA table_info(table)`. @@ -51,7 +50,7 @@ mod pragma_fk_col { use super::SqliteError; use super::info::sqlite_bare_type_name; -use super::{SqliteBackend, SqliteConnection, SqliteStatement, map_sqlite_error}; +use super::{SqliteConnection, map_sqlite_error}; use crate::type_conversion::{ default_precision_for_type, sqlite_declared_type_precision, sqlite_declared_type_scale, sqlite_type_to_sql_data_type, @@ -72,15 +71,6 @@ fn fk_action_to_odbc(action: &str) -> i16 { } } -/// Column descriptors for the `SQLTables` result set. -/// -/// Shared with every other driver via [`TablesResultCol::all_descriptors`]. -/// Widths come from `catalog_result_column_widths()` so they stay consistent -/// with this driver's `SQL_MAX_TABLE_NAME_LEN` of 128. -fn tables_columns() -> Vec<ColumnDescriptor> { - TablesResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()) -} - /// Query `sqlite_master` for table **and view** names, filtered by an optional /// LIKE pattern. Used by `SQLColumns`, whose `TableName` argument is a search /// pattern and whose result set is defined over tables and views alike. @@ -120,7 +110,7 @@ fn build_column_row( not_null: bool, ordinal: i64, dflt_value: Option<&str>, -) -> Vec<ColumnValue> { +) -> ColumnRow { let sql_type = sqlite_type_to_sql_data_type(col_type); let precision = sqlite_declared_type_precision(col_type); let scale = sqlite_declared_type_scale(col_type); @@ -167,15 +157,10 @@ fn build_column_row( i32::try_from(precision) .ok() .and_then(|p| p.checked_mul(BYTES_PER_CHAR)) - .map(ColumnValue::I32) - .unwrap_or(ColumnValue::Null) } else if is_binary { - i32::try_from(precision) - .ok() - .map(ColumnValue::I32) - .unwrap_or(ColumnValue::Null) + i32::try_from(precision).ok() } else { - ColumnValue::Null + None }; let ordinal_position = i32::try_from(ordinal + 1).unwrap_or_else(|_| { @@ -183,12 +168,12 @@ fn build_column_row( i32::MAX }); - vec![ - ColumnValue::Null, // TABLE_CAT - ColumnValue::Null, // TABLE_SCHEM - ColumnValue::String(table_name.to_string()), // TABLE_NAME - ColumnValue::String(col_name.to_string()), // COLUMN_NAME - ColumnValue::I16(sql_type.0), // DATA_TYPE + ColumnRow { + catalog: None, + schema: None, + table_name: table_name.to_string(), + column_name: col_name.to_string(), + data_type: sql_type.0, // Spec (SQLColumns.TYPE_NAME / SQL_DESC_TYPE_NAME): both list bare // examples ("CHAR", "VARCHAR", ...), not declarations, so `col_type` // ("VARCHAR(50)") matches no `SQLGetTypeInfo` row. @@ -196,27 +181,20 @@ fn build_column_row( // execute.rs) returns the bare name that does; the declared length // is still carried above via COLUMN_SIZE (`precision`), just not the // name. - ColumnValue::String(sqlite_bare_type_name(sql_type).to_string()), // TYPE_NAME - ColumnValue::I32(column_size), // COLUMN_SIZE - ColumnValue::I32(0), // BUFFER_LENGTH - ColumnValue::I16(scale), // DECIMAL_DIGITS - if is_numeric { - ColumnValue::I16(10) - } else { - ColumnValue::Null - }, // NUM_PREC_RADIX - ColumnValue::I16(nullable.into()), // NULLABLE - ColumnValue::Null, // REMARKS - match dflt_value { - Some(v) => ColumnValue::String(v.to_string()), - None => ColumnValue::Null, - }, // COLUMN_DEF - ColumnValue::I16(sql_type.0), // SQL_DATA_TYPE - ColumnValue::Null, // SQL_DATETIME_SUB - char_octet_length, // CHAR_OCTET_LENGTH - ColumnValue::I32(ordinal_position), // ORDINAL_POSITION - ColumnValue::String(nullable.as_is_nullable_str().to_string()), // IS_NULLABLE - ] + type_name: sqlite_bare_type_name(sql_type).to_string(), + column_size: Some(column_size), + buffer_length: Some(0), + decimal_digits: Some(scale), + num_prec_radix: if is_numeric { Some(10) } else { None }, + nullable: nullable.into(), + remarks: None, + column_def: dflt_value.map(str::to_string), + sql_data_type: sql_type.0, + sql_datetime_sub: None, + char_octet_length, + ordinal_position, + is_nullable: Some(nullable.as_is_nullable_str().to_string()), + } } /// Return the base tables to inspect: the exact named table if one is given, @@ -243,65 +221,58 @@ fn tables_to_inspect( Ok(names) } +/// The table types SQLite exposes, for `SQLTables`' `SQL_ALL_TABLE_TYPES` +/// enumeration. +/// +/// `sqlite_master.type` also carries `index` and `trigger`, but neither is a +/// table type: `SQLTables`' result set is defined over tables and views, and +/// this list must name exactly the values [`tables`] can put in `TABLE_TYPE`. +/// +/// Upper case per the spec, which has applications specify table types in +/// upper case and the driver map them to whatever the data source needs -- +/// SQLite spells its own lower case, and [`tables`] does that mapping. +pub(super) fn table_types() -> Vec<std::borrow::Cow<'static, str>> { + vec![ + std::borrow::Cow::Borrowed(TABLE_TYPE_TABLE), + std::borrow::Cow::Borrowed(TABLE_TYPE_VIEW), + ] +} + +/// The two `TABLE_TYPE` values this driver reports, named so [`table_types`] +/// and [`tables`] cannot drift apart. +const TABLE_TYPE_TABLE: &str = "TABLE"; +const TABLE_TYPE_VIEW: &str = "VIEW"; + +/// Rows for `SQLTables`. +/// +/// The `SQL_ALL_CATALOGS` / `SQL_ALL_SCHEMAS` / `SQL_ALL_TABLE_TYPES` +/// enumerations no longer reach here: core detects them from the raw arguments +/// and answers them from `supports_catalogs`, `supports_schemas` and +/// [`table_types`]. Rows are returned unsorted; core orders them by +/// TABLE_TYPE, TABLE_CAT, TABLE_SCHEM, TABLE_NAME. pub(super) fn tables( conn: &SqliteConnection, - catalog: Option<&str>, - schema: Option<&str>, + _catalog: Option<&str>, + _schema: Option<&str>, table: Option<&str>, table_type: Option<&str>, -) -> Result<SqliteStatement, SqliteError> { - // ODBC spec: empty string is a valid (but useless for SQLite) filter; treat as no-filter. - let catalog = catalog.filter(|s| !s.is_empty()); - let schema = schema.filter(|s| !s.is_empty()); - // The SQLTables TableType="%" discovery case requires an EMPTY TableName; - // evaluate that before the "%"-stripping normalization below, so a literal - // TableName="%" (meaning "all tables") does not masquerade as discovery. - let table_name_is_empty = table.is_none_or(|s| s.is_empty()); - // Treat "%" (match-all wildcard) as no-filter too, to avoid LIKE '%' overhead. +) -> Result<Vec<TableRow>, SqliteError> { + // ODBC spec: empty string is a valid (but useless for SQLite) filter; treat + // as no-filter. Treat "%" (match-all wildcard) as no-filter too, to avoid + // LIKE '%' overhead -- an ordinary query is all that can arrive now. let table = table.filter(|s| !s.is_empty() && *s != "%"); - - // ODBC spec §SQLTables: TableType="%" with empty catalog/schema/table returns - // the list of valid table types for the data source. SQLite exposes tables - // and views. - if table_type == Some("%") && catalog.is_none() && schema.is_none() && table_name_is_empty { - let type_rows = ["TABLE", "VIEW"] - .into_iter() - .map(|t| { - vec![ - ColumnValue::Null, // TABLE_CAT - ColumnValue::Null, // TABLE_SCHEM - ColumnValue::Null, // TABLE_NAME - ColumnValue::String(t.to_string()), // TABLE_TYPE - ColumnValue::Null, // REMARKS - ] - }) - .collect(); - return Ok(SqliteStatement::new(tables_columns(), type_rows)); - } - let table_type = table_type.filter(|s| !s.is_empty() && *s != "%"); - // ODBC spec §8.3: special single-argument discovery calls. - // catalog="%" with empty schema/table → return list of valid catalogs. - // SQLite has no catalogs (TABLE_CAT is always NULL), so return empty result. - if catalog == Some("%") && schema.is_none() && table.is_none() { - return Ok(SqliteStatement::new(tables_columns(), vec![])); - } - // schema="%" with empty catalog/table → return list of valid schemas. - // SQLite has no schemas, so return empty result. - if schema == Some("%") && catalog.is_none() && table.is_none() { - return Ok(SqliteStatement::new(tables_columns(), vec![])); - } - let db = conn.conn.lock().map_err(|e| SqliteError::General { message: format!("Mutex poisoned: {e}"), })?; - // TableName is a search pattern (ODBC §8.3); use LIKE so "%" works as wildcard. + // TableName is a search pattern (ODBC §8.3); use LIKE so "%" works as + // wildcard. No ORDER BY: core sorts the result set. let sql = if table.is_some() { - "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name LIKE ?1 ESCAPE '\\' ORDER BY type, name" + "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name LIKE ?1 ESCAPE '\\'" } else { - "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY type, name" + "SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view')" }; let mut stmt = db.prepare(sql).map_err(map_sqlite_error)?; @@ -314,7 +285,11 @@ pub(super) fn tables( while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { let name: String = row.get(0).map_err(map_sqlite_error)?; let type_str: String = row.get(1).map_err(map_sqlite_error)?; - let odbc_type = if type_str == "view" { "VIEW" } else { "TABLE" }; + let odbc_type = if type_str == "view" { + TABLE_TYPE_VIEW + } else { + TABLE_TYPE_TABLE + }; // Filter by table_type if specified (comma-separated list, not a pattern). if let Some(tt) = table_type { @@ -324,16 +299,16 @@ pub(super) fn tables( } } - rows.push(vec![ - ColumnValue::Null, // TABLE_CAT - ColumnValue::Null, // TABLE_SCHEM - ColumnValue::String(name), // TABLE_NAME - ColumnValue::String(odbc_type.to_string()), // TABLE_TYPE - ColumnValue::Null, // REMARKS - ]); + rows.push(TableRow { + catalog: None, + schema: None, + name: Some(name), + table_type: Some(odbc_type.to_string()), + remarks: None, + }); } - Ok(SqliteStatement::new(tables_columns(), rows)) + Ok(rows) } pub(super) fn columns( @@ -342,7 +317,7 @@ pub(super) fn columns( _schema: Option<&str>, table: Option<&str>, column: Option<&str>, -) -> Result<SqliteStatement, SqliteError> { +) -> Result<Vec<ColumnRow>, SqliteError> { // Same normalization as tables(): empty string and "%" both mean "no filter". let table = table.filter(|s| !s.is_empty() && *s != "%"); let column = column.filter(|s| !s.is_empty() && *s != "%"); @@ -406,9 +381,7 @@ pub(super) fn columns( } } - let columns = ColumnsResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()); - - Ok(SqliteStatement::new(columns, rows)) + Ok(rows) } /// Return primary key columns for the given table. @@ -416,13 +389,16 @@ pub(super) fn columns( /// Uses `PRAGMA table_info(table)` and filters rows where `pk > 0`. /// The `pk` column is the 1-based key sequence number. /// +/// Rows are returned unsorted; core orders them by TABLE_CAT, TABLE_SCHEM, +/// TABLE_NAME, KEY_SEQ. +/// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlprimarykeys-function> pub(super) fn primary_keys( conn: &SqliteConnection, _catalog: Option<&str>, _schema: Option<&str>, table: Option<&str>, -) -> Result<SqliteStatement, SqliteError> { +) -> Result<Vec<PrimaryKeyRow>, SqliteError> { let db = conn.conn.lock().map_err(|e| SqliteError::General { message: format!("Mutex poisoned: {e}"), })?; @@ -430,7 +406,7 @@ pub(super) fn primary_keys( // Collect table names to query (either the specific one or all tables). let table_names = tables_to_inspect(&db, table).map_err(map_sqlite_error)?; - let mut result_rows: Vec<Vec<ColumnValue>> = Vec::new(); + let mut result_rows: Vec<PrimaryKeyRow> = Vec::new(); for table_name in &table_names { // The pragma_table_info table-valued function binds its argument (the // `PRAGMA table_info(...)` statement form does not); use it so the name @@ -456,28 +432,25 @@ pub(super) fn primary_keys( } } - // Sort by KEY_SEQ (the pk column from PRAGMA is already 1-based). - pk_cols.sort_by_key(|(seq, _)| *seq); - + // Not sorted by KEY_SEQ here: core sorts the result set on that key, + // and a second ordering in the backend is one more place for it to be + // wrong. for (key_seq, col_name) in pk_cols { - result_rows.push(vec![ - ColumnValue::Null, // TABLE_CAT - ColumnValue::Null, // TABLE_SCHEM - ColumnValue::String(table_name.clone()), // TABLE_NAME - ColumnValue::String(col_name), // COLUMN_NAME - ColumnValue::I16(i16::try_from(key_seq).unwrap_or_else(|_| { + result_rows.push(PrimaryKeyRow { + catalog: None, + schema: None, + table_name: table_name.clone(), + column_name: col_name, + key_seq: i16::try_from(key_seq).unwrap_or_else(|_| { tracing::warn!(key_seq, "key sequence exceeds i16"); i16::MAX - })), // KEY_SEQ - ColumnValue::Null, // PK_NAME (not available in SQLite) - ]); + }), + pk_name: None, // not available in SQLite + }); } } - let columns = - PrimaryKeysResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()); - - Ok(SqliteStatement::new(columns, result_rows)) + Ok(result_rows) } /// Return foreign key relationships involving the given tables. @@ -494,7 +467,7 @@ pub(super) fn foreign_keys( _fk_catalog: Option<&str>, _fk_schema: Option<&str>, fk_table: Option<&str>, -) -> Result<SqliteStatement, SqliteError> { +) -> Result<Vec<ForeignKeyRow>, SqliteError> { let db = conn.conn.lock().map_err(|e| SqliteError::General { message: format!("Mutex poisoned: {e}"), })?; @@ -502,7 +475,7 @@ pub(super) fn foreign_keys( // Which FK tables do we query? let fk_table_names = tables_to_inspect(&db, fk_table).map_err(map_sqlite_error)?; - let mut result_rows: Vec<Vec<ColumnValue>> = Vec::new(); + let mut result_rows: Vec<ForeignKeyRow> = Vec::new(); for fk_tbl in &fk_table_names { let pragma_sql = format!("PRAGMA foreign_key_list('{}')", fk_tbl.replace('\'', "''")); @@ -529,48 +502,87 @@ pub(super) fn foreign_keys( continue; } - let pk_col = match to_col { - Some(c) => ColumnValue::String(c), - // SQLite allows FK without explicit column; treat as NULL. - None => ColumnValue::Null, + // PKCOLUMN_NAME is one of the four columns the spec marks "not + // NULL", which `ForeignKeyRow` enforces. `PRAGMA foreign_key_list` + // leaves `to` NULL for an implicit reference (`REFERENCES parent` + // with no column list), which SQLite defines as referencing the + // parent's PRIMARY KEY -- so the name is recoverable, and is + // resolved rather than reported as a NULL the column cannot hold. + let pk_column_name = match to_col { + Some(c) => c, + None => parent_pk_column(&db, &referenced_table, seq)?.unwrap_or_else(|| { + // Reachable only for a schema SQLite itself rejects at DML + // time ("foreign key mismatch"), so there is no correct + // name to report. + tracing::warn!( + parent = %referenced_table, + seq, + "foreign key references a parent with no primary key column at \ + this position; reporting PKCOLUMN_NAME as an empty string" + ); + String::new() + }), }; - result_rows.push(vec![ - ColumnValue::Null, // PKTABLE_CAT - ColumnValue::Null, // PKTABLE_SCHEM - ColumnValue::String(referenced_table), // PKTABLE_NAME - pk_col, // PKCOLUMN_NAME - ColumnValue::Null, // FKTABLE_CAT - ColumnValue::Null, // FKTABLE_SCHEM - ColumnValue::String(fk_tbl.clone()), // FKTABLE_NAME - ColumnValue::String(from_col), // FKCOLUMN_NAME - ColumnValue::I16(i16::try_from(seq + 1).unwrap_or_else(|_| { + result_rows.push(ForeignKeyRow { + pk_catalog: None, + pk_schema: None, + pk_table_name: referenced_table, + pk_column_name, + fk_catalog: None, + fk_schema: None, + fk_table_name: fk_tbl.clone(), + fk_column_name: from_col, + key_seq: i16::try_from(seq + 1).unwrap_or_else(|_| { tracing::warn!(seq, "key sequence exceeds i16"); i16::MAX - })), // KEY_SEQ (1-based) - ColumnValue::I16(fk_action_to_odbc(&on_update)), // UPDATE_RULE - ColumnValue::I16(fk_action_to_odbc(&on_delete)), // DELETE_RULE - ColumnValue::Null, // FK_NAME (not in SQLite PRAGMA) - ColumnValue::Null, // PK_NAME (not in SQLite PRAGMA) - ColumnValue::Null, // DEFERRABILITY - ]); + }), // 1-based + update_rule: Some(fk_action_to_odbc(&on_update)), + delete_rule: Some(fk_action_to_odbc(&on_delete)), + fk_name: None, // not in SQLite's PRAGMA + pk_name: None, // not in SQLite's PRAGMA + deferrability: None, + }); } } - let columns = - ForeignKeysResultCol::all_descriptors(&SqliteBackend::catalog_result_column_widths()); + Ok(result_rows) +} - Ok(SqliteStatement::new(columns, result_rows)) +/// The parent table's primary key column at position `seq` (0-based), for a +/// foreign key declared without an explicit column list. +/// +/// `PRAGMA table_info`'s `pk` column is the 1-based position within the +/// primary key, so the column wanted is the one with `pk == seq + 1`. +fn parent_pk_column( + db: &rusqlite::Connection, + parent: &str, + seq: i64, +) -> Result<Option<String>, SqliteError> { + let mut stmt = db + .prepare("SELECT name FROM pragma_table_info(?1) WHERE pk = ?2") + .map_err(map_sqlite_error)?; + let mut rows = stmt + .query(rusqlite::params![parent, seq + 1]) + .map_err(map_sqlite_error)?; + match rows.next().map_err(map_sqlite_error)? { + Some(row) => Ok(Some(row.get(0).map_err(map_sqlite_error)?)), + None => Ok(None), + } } /// Return index statistics for a single table (SQLStatistics). /// -/// Emits a leading `SQL_TABLE_STAT` row (CARDINALITY from `sqlite_stat1` when +/// Emits a `SQL_TABLE_STAT` row (CARDINALITY from `sqlite_stat1` when /// `ANALYZE` has populated it, else NULL; PAGES always NULL, honoring /// SQL_QUICK), then one row per key column of each index from -/// `PRAGMA index_list` / `PRAGMA index_xinfo`. Rows are ordered per spec by -/// NON_UNIQUE, TYPE, INDEX_QUALIFIER (always NULL here), INDEX_NAME, -/// ORDINAL_POSITION, with the NULL NON_UNIQUE table-stat row first. +/// `PRAGMA index_list` / `PRAGMA index_xinfo`. +/// +/// Rows are returned unsorted. Core orders them per spec by NON_UNIQUE, TYPE, +/// INDEX_QUALIFIER, INDEX_NAME, ORDINAL_POSITION, and the table-stat row still +/// comes first because its NON_UNIQUE is NULL and this driver reports +/// `SQL_NC_LOW` for `SQL_NULL_COLLATION` -- core's sorter takes NULL placement +/// from that hook rather than choosing for itself. /// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlstatistics-function> pub(super) fn statistics( @@ -579,16 +591,13 @@ pub(super) fn statistics( _schema: Option<&str>, table: Option<&str>, unique_only: bool, -) -> Result<SqliteStatement, SqliteError> { +) -> Result<Vec<StatisticsRow>, SqliteError> { use stackable_odbc_core::types::{SQL_FALSE, SQL_TRUE}; - let widths = SqliteBackend::catalog_result_column_widths(); - let columns = statistics_columns(&widths); - // SQLStatistics.TableName cannot be a search pattern; an absent name has no - // table to describe, so return an empty (but correctly-shaped) result set. + // table to describe, so there are no rows to report. let Some(table) = table.filter(|s| !s.is_empty()) else { - return Ok(SqliteStatement::new(columns, Vec::new())); + return Ok(Vec::new()); }; let db = conn.conn.lock().map_err(|e| SqliteError::General { @@ -598,23 +607,23 @@ pub(super) fn statistics( // CARDINALITY for the table-stat row: read sqlite_stat1 only if present. let cardinality = table_cardinality_from_stat1(&db, table); - // Table-stat row (leading). TABLE_NAME (3) and TYPE (7) are the NOT NULL - // columns; everything index-specific is NULL. - let mut rows: Vec<Vec<ColumnValue>> = vec![vec![ - ColumnValue::Null, // TABLE_CAT - ColumnValue::Null, // TABLE_SCHEM - ColumnValue::String(table.to_string()), // TABLE_NAME - ColumnValue::Null, // NON_UNIQUE - ColumnValue::Null, // INDEX_QUALIFIER - ColumnValue::Null, // INDEX_NAME - ColumnValue::I16(SQL_TABLE_STAT), // TYPE - ColumnValue::Null, // ORDINAL_POSITION - ColumnValue::Null, // COLUMN_NAME - ColumnValue::Null, // ASC_OR_DESC - cardinality, // CARDINALITY - ColumnValue::Null, // PAGES - ColumnValue::Null, // FILTER_CONDITION - ]]; + // The table-stat row. TABLE_NAME and TYPE are the NOT NULL columns; + // everything index-specific is NULL. + let mut rows: Vec<StatisticsRow> = vec![StatisticsRow { + catalog: None, + schema: None, + table_name: table.to_string(), + non_unique: None, + index_qualifier: None, + index_name: None, + index_type: SQL_TABLE_STAT, + ordinal_position: None, + column_name: None, + asc_or_desc: None, + cardinality, + pages: None, + filter_condition: None, + }]; // Enumerate indexes. Use the pragma_ TVF form so the name binds safely. let mut list_stmt = db @@ -670,76 +679,48 @@ pub(super) fn statistics( .get(pragma_index_xinfo_col::DESC) .map_err(map_sqlite_error)?; - rows.push(vec![ - ColumnValue::Null, // TABLE_CAT - ColumnValue::Null, // TABLE_SCHEM - ColumnValue::String(table.to_string()), // TABLE_NAME - ColumnValue::I16(if *is_unique { + rows.push(StatisticsRow { + catalog: None, + schema: None, + table_name: table.to_string(), + non_unique: Some(if *is_unique { SQL_FALSE as i16 } else { SQL_TRUE as i16 - }), // NON_UNIQUE - ColumnValue::Null, // INDEX_QUALIFIER - ColumnValue::String(index_name.clone()), // INDEX_NAME - ColumnValue::I16(SQL_INDEX_OTHER), // TYPE - ColumnValue::I16(ordinal), // ORDINAL_POSITION - ColumnValue::String(col_name.unwrap_or_default()), // COLUMN_NAME ("" for expression) - ColumnValue::String(if desc != 0 { "D" } else { "A" }.into()), // ASC_OR_DESC - ColumnValue::Null, // CARDINALITY - ColumnValue::Null, // PAGES - if *is_partial { - ColumnValue::String(String::new()) + }), + index_qualifier: None, + index_name: Some(index_name.clone()), + index_type: SQL_INDEX_OTHER, + ordinal_position: Some(ordinal), + // "" for an expression index, which has no column name. + column_name: Some(col_name.unwrap_or_default()), + asc_or_desc: Some(if desc != 0 { "D" } else { "A" }.into()), + cardinality: None, + pages: None, + filter_condition: if *is_partial { + Some(String::new()) } else { - ColumnValue::Null - }, // FILTER_CONDITION - ]); + None + }, + }); } } - // Order: table-stat row first (NON_UNIQUE NULL), then by NON_UNIQUE, TYPE, - // INDEX_NAME, ORDINAL_POSITION. Sort key extracts those columns. - rows.sort_by_key(|r| statistics_sort_key(r)); - - Ok(SqliteStatement::new(columns, rows)) -} - -/// Sort key implementing the SQLStatistics ordering. NULL NON_UNIQUE sorts -/// first (the table-stat row); NON_UNIQUE ascending (unique before non-unique); -/// then TYPE, INDEX_NAME, ORDINAL_POSITION. -fn statistics_sort_key(row: &[ColumnValue]) -> (i16, i16, String, i16) { - let non_unique = match &row[3] { - ColumnValue::I16(v) => *v, - _ => -1, // NULL -> before 0 (unique) and 1 (non-unique) - }; - let ty = match &row[6] { - ColumnValue::I16(v) => *v, - _ => 0, - }; - let index_name = match &row[5] { - ColumnValue::String(s) => s.clone(), - _ => String::new(), - }; - let ordinal = match &row[7] { - ColumnValue::I16(v) => *v, - _ => 0, - }; - (non_unique, ty, index_name, ordinal) + Ok(rows) } /// Read the table row count from `sqlite_stat1` if `ANALYZE` has populated it. /// The `stat` column's first whitespace-delimited token is the table row count. -/// Returns `ColumnValue::Null` when the stat table or row is absent. -fn table_cardinality_from_stat1(db: &rusqlite::Connection, table: &str) -> ColumnValue { +/// Returns `None` when the stat table or row is absent. +fn table_cardinality_from_stat1(db: &rusqlite::Connection, table: &str) -> Option<i32> { let query = "SELECT stat FROM sqlite_stat1 WHERE tbl = ?1 AND idx IS NULL LIMIT 1"; let stat: Result<String, _> = db.query_row(query, rusqlite::params![table], |r| r.get(0)); match stat { Ok(s) => s .split_whitespace() .next() - .and_then(|tok| tok.parse::<i32>().ok()) - .map(ColumnValue::I32) - .unwrap_or(ColumnValue::Null), - Err(_) => ColumnValue::Null, // no sqlite_stat1 (no ANALYZE) or no row + .and_then(|tok| tok.parse::<i32>().ok()), + Err(_) => None, // no sqlite_stat1 (no ANALYZE) or no row } } @@ -762,10 +743,8 @@ pub(super) fn special_columns( table: Option<&str>, scope: Scope, _nullable: Nullable, // our identifiers are all NOT NULL -> Nullable never filters -) -> Result<SqliteStatement, SqliteError> { - let widths = SqliteBackend::catalog_result_column_widths(); - let columns = special_columns_columns(&widths); - let empty = || Ok(SqliteStatement::new(columns.clone(), Vec::new())); +) -> Result<Vec<SpecialColumnRow>, SqliteError> { + let empty = || Ok(Vec::new()); // ROWVER: SQLite has no auto-updated columns. if matches!(identifier_type, IdentifierType::RowVer) { @@ -831,7 +810,7 @@ pub(super) fn special_columns( // Decide identifier + guaranteed scope. // guaranteed scope: TRANSACTION for the volatile rowid pseudo-column, // SESSION for a declared key column. - let (rows, guaranteed): (Vec<Vec<ColumnValue>>, Scope) = if let Some(col) = integer_pk { + let (rows, guaranteed): (Vec<SpecialColumnRow>, Scope) = if let Some(col) = integer_pk { // A declared INTEGER PRIMARY KEY is an alias for the 8-byte 64-bit // rowid, not a plain INTEGER column, so describe it with the same // BIGINT/COLUMN_SIZE 19/BUFFER_LENGTH 8 shape as the rowid @@ -875,46 +854,43 @@ pub(super) fn special_columns( return empty(); } - Ok(SqliteStatement::new(columns, rows)) + Ok(rows) } /// Build one SQLSpecialColumns row for a declared column, deriving its SQL type /// from the same mapping SQLColumns uses. -fn special_column_row(name: &str, decl_type: &str, pseudo: i16, scope: Scope) -> Vec<ColumnValue> { +fn special_column_row(name: &str, decl_type: &str, pseudo: i16, scope: Scope) -> SpecialColumnRow { let sql_type = sqlite_type_to_sql_data_type(decl_type); let column_size = i32::try_from(sqlite_declared_type_precision(decl_type)).unwrap_or(i32::MAX); let scale = sqlite_declared_type_scale(decl_type); - vec![ - ColumnValue::I16(scope.into()), // SCOPE - ColumnValue::String(name.to_string()), // COLUMN_NAME - ColumnValue::I16(sql_type.0), // DATA_TYPE - ColumnValue::String(sqlite_bare_type_name(sql_type).to_string()), // TYPE_NAME - ColumnValue::I32(column_size), // COLUMN_SIZE - ColumnValue::I32(column_size), // BUFFER_LENGTH (approx: transfer octet length) - if scale > 0 { - ColumnValue::I16(scale) - } else { - ColumnValue::Null - }, // DECIMAL_DIGITS - ColumnValue::I16(pseudo), // PSEUDO_COLUMN - ] + SpecialColumnRow { + scope: Some(scope.into()), + column_name: name.to_string(), + data_type: sql_type.0, + type_name: sqlite_bare_type_name(sql_type).to_string(), + column_size: Some(column_size), + // BUFFER_LENGTH (approx: transfer octet length) + buffer_length: Some(column_size), + decimal_digits: if scale > 0 { Some(scale) } else { None }, + pseudo_column: Some(pseudo), + } } /// Build one SQLSpecialColumns row for the 64-bit rowid pseudo-column / an /// INTEGER PRIMARY KEY reported as BIGINT. -fn special_column_row_bigint(name: &str, pseudo: i16, scope: Scope) -> Vec<ColumnValue> { +fn special_column_row_bigint(name: &str, pseudo: i16, scope: Scope) -> SpecialColumnRow { let sql_type = SqlDataType::EXT_BIG_INT; let column_size = i32::try_from(default_precision_for_type(sql_type)).unwrap_or(i32::MAX); - vec![ - ColumnValue::I16(scope.into()), - ColumnValue::String(name.to_string()), - ColumnValue::I16(sql_type.0), - ColumnValue::String(sqlite_bare_type_name(sql_type).to_string()), - ColumnValue::I32(column_size), - ColumnValue::I32(8), // BUFFER_LENGTH: 8 bytes for a 64-bit integer - ColumnValue::Null, // DECIMAL_DIGITS: not applicable to integers - ColumnValue::I16(pseudo), - ] + SpecialColumnRow { + scope: Some(scope.into()), + column_name: name.to_string(), + data_type: sql_type.0, + type_name: sqlite_bare_type_name(sql_type).to_string(), + column_size: Some(column_size), + buffer_length: Some(8), // 8 bytes for a 64-bit integer + decimal_digits: None, // not applicable to integers + pseudo_column: Some(pseudo), + } } /// True if `table` is an ordinary rowid table. Probes `SELECT rowid`: a @@ -941,16 +917,31 @@ fn table_is_rowid(db: &rusqlite::Connection, table: &str) -> Result<bool, Sqlite Err(e) => Err(map_sqlite_error(e)), } } - #[cfg(test)] mod tests { use std::sync::Mutex; use super::*; use crate::backend::SqliteConnection; - use stackable_odbc_core::backend::StatementBackend; - use stackable_odbc_core::types::{CDataType, FetchResult, SQL_FALSE}; + use stackable_odbc_core::types::SQL_FALSE; + /// Wrap a raw `rusqlite::Connection` the way [`SqliteBackend::connect`] + /// does, including the interrupt handle every statement's cancel token is + /// cloned from. + fn wrap(conn: rusqlite::Connection) -> SqliteConnection { + let interrupt = std::sync::Arc::new(conn.get_interrupt_handle()); + SqliteConnection { + conn: Mutex::new(conn), + interrupt, + manual_commit: std::sync::atomic::AtomicBool::new(false), + } + } + + /// These tests assert what the backend now owns: which rows exist and what + /// each column holds. Column *order* and row *order* moved to core, which + /// converts these structs to the spec's layout and sorts them — so an + /// ordering assertion belongs at the FFI level, where core's sort has + /// actually run, not here. See `ffi_integration_tests.rs`. fn setup_test_db() -> SqliteConnection { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch( @@ -965,125 +956,130 @@ mod tests { );", ) .unwrap(); - SqliteConnection { - conn: Mutex::new(conn), - manual_commit: std::sync::atomic::AtomicBool::new(false), - } + wrap(conn) } #[test] fn tables_returns_all_tables_and_views() { let conn = setup_test_db(); - let mut stmt = tables(&conn, None, None, None, None).unwrap(); - assert_eq!(stmt.column_count(), 5); - - let mut names = Vec::new(); - let mut types = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - if let ColumnValue::String(n) = - stmt.get_data(3, CDataType::Default).unwrap().into_owned() - { - names.push(n); - } - if let ColumnValue::String(t) = - stmt.get_data(4, CDataType::Default).unwrap().into_owned() - { - types.push(t); - } + let rows = tables(&conn, None, None, None, None).unwrap(); + + let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); + for expected in ["empty_table", "types_test", "types_view", "parent", "child"] { + assert!(names.contains(&expected), "missing {expected} in {names:?}"); } - assert!(names.contains(&"empty_table".to_string())); - assert!(names.contains(&"types_test".to_string())); - assert!(names.contains(&"types_view".to_string())); - assert!(names.contains(&"parent".to_string())); - assert!(names.contains(&"child".to_string())); - assert_eq!(types.iter().filter(|t| *t == "TABLE").count(), 4); - assert_eq!(types.iter().filter(|t| *t == "VIEW").count(), 1); + + let types: Vec<&str> = rows + .iter() + .filter_map(|r| r.table_type.as_deref()) + .collect(); + assert_eq!(types.iter().filter(|t| **t == TABLE_TYPE_TABLE).count(), 4); + assert_eq!(types.iter().filter(|t| **t == TABLE_TYPE_VIEW).count(), 1); + + // SQLite has neither, and every row says so. + assert!( + rows.iter() + .all(|r| r.catalog.is_none() && r.schema.is_none()) + ); + } + + /// Every value [`table_types`] declares must be one [`tables`] can actually + /// put in `TABLE_TYPE`, and vice versa. Core serves `SQL_ALL_TABLE_TYPES` + /// from the first and the result set from the second, so a mismatch is a + /// data source that lists a type no query returns. + #[test] + fn declared_table_types_are_exactly_the_ones_tables_reports() { + let conn = setup_test_db(); + let declared: Vec<String> = table_types().iter().map(|t| t.to_string()).collect(); + + let mut reported: Vec<String> = tables(&conn, None, None, None, None) + .unwrap() + .into_iter() + .filter_map(|r| r.table_type) + .collect(); + reported.sort(); + reported.dedup(); + + let mut declared_sorted = declared.clone(); + declared_sorted.sort(); + assert_eq!( + declared_sorted, reported, + "table_types() and the TABLE_TYPE values tables() emits disagree" + ); } #[test] fn tables_filter_by_table_type() { let conn = setup_test_db(); - let mut stmt = tables(&conn, None, None, None, Some("TABLE")).unwrap(); - let mut names = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - if let ColumnValue::String(n) = - stmt.get_data(3, CDataType::Default).unwrap().into_owned() - { - names.push(n); - } - } - assert!(names.contains(&"empty_table".to_string())); - assert!(names.contains(&"types_test".to_string())); - assert!(!names.contains(&"types_view".to_string())); + let rows = tables(&conn, None, None, None, Some(TABLE_TYPE_TABLE)).unwrap(); + let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); + assert!(names.contains(&"empty_table")); + assert!(names.contains(&"types_test")); + assert!(!names.contains(&"types_view")); } #[test] fn tables_filter_by_name() { let conn = setup_test_db(); - let mut stmt = tables(&conn, None, None, Some("types_test"), None).unwrap(); - let mut count = 0; - while stmt.fetch().unwrap() == FetchResult::Row { - count += 1; - assert_eq!( - stmt.get_data(3, CDataType::Default).unwrap().into_owned(), - ColumnValue::String("types_test".to_string()) - ); - } - assert_eq!(count, 1); + let rows = tables(&conn, None, None, Some("types_test"), None).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name.as_deref(), Some("types_test")); } #[test] - fn tables_table_type_percent_lists_table_types() { + fn tables_table_name_honors_escape_character() { let conn = setup_test_db(); - // SQL_ALL_TABLE_TYPES discovery: TableType="%", others empty. - let mut stmt = tables(&conn, Some(""), Some(""), Some(""), Some("%")).unwrap(); - let mut types = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - // TABLE_NAME (col 3) must be NULL for the discovery result set. - assert_eq!( - stmt.get_data(3, CDataType::Default).unwrap().into_owned(), - ColumnValue::Null - ); - if let ColumnValue::String(s) = - stmt.get_data(4, CDataType::Default).unwrap().into_owned() - { - types.push(s); - } - } - types.sort(); - assert_eq!(types, vec!["TABLE".to_string(), "VIEW".to_string()]); + // `empty\_table` with ESCAPE '\' means a literal underscore: matches + // exactly "empty_table". Without ESCAPE the `_` is a wildcard and the + // stray backslash matches nothing. + let rows = tables(&conn, None, None, Some("empty\\_table"), None).unwrap(); + let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); + assert_eq!(names, vec!["empty_table"]); } #[test] - fn columns_returns_correct_columns() { + fn tables_table_type_percent_with_table_wildcard_lists_tables() { let conn = setup_test_db(); - let mut stmt = columns(&conn, None, None, Some("types_test"), None).unwrap(); + // TableType="%" with TableName="%" is not an enumeration — core only + // treats "%" as `SQL_ALL_TABLE_TYPES` when the other three arguments + // are empty strings, so this reaches the backend as an ordinary query + // and must list actual tables and views. + let rows = tables(&conn, Some(""), Some(""), Some("%"), Some("%")).unwrap(); + let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); + assert!( + names.contains(&"types_test"), + "expected real table listing, got {names:?}" + ); + } - let mut col_names = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - if let ColumnValue::String(n) = - stmt.get_data(4, CDataType::Default).unwrap().into_owned() - { - col_names.push(n); - } - } - assert_eq!(col_names, vec!["id", "val", "label"]); + #[test] + fn columns_returns_correct_columns() { + let conn = setup_test_db(); + let rows = columns(&conn, None, None, Some("types_test"), None).unwrap(); + let names: Vec<&str> = rows.iter().map(|r| r.column_name.as_str()).collect(); + assert_eq!(names, vec!["id", "val", "label"]); + // ORDINAL_POSITION is 1-based and is what core sorts on. + let ordinals: Vec<i32> = rows.iter().map(|r| r.ordinal_position).collect(); + assert_eq!(ordinals, vec![1, 2, 3]); } #[test] fn columns_filter_by_column_name() { let conn = setup_test_db(); - let mut stmt = columns(&conn, None, None, Some("types_test"), Some("val")).unwrap(); - - let mut count = 0; - while stmt.fetch().unwrap() == FetchResult::Row { - count += 1; - assert_eq!( - stmt.get_data(4, CDataType::Default).unwrap().into_owned(), - ColumnValue::String("val".to_string()) - ); - } - assert_eq!(count, 1); + let rows = columns(&conn, None, None, Some("types_test"), Some("val")).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].column_name, "val"); + } + + #[test] + fn columns_column_name_is_a_like_pattern() { + let conn = setup_test_db(); + // types_test columns: id, val, label. "%l%" matches val and label. + // Under the old exact-match filter this returned zero rows. + let rows = columns(&conn, None, None, Some("types_test"), Some("%l%")).unwrap(); + let mut names: Vec<&str> = rows.iter().map(|r| r.column_name.as_str()).collect(); + names.sort(); + assert_eq!(names, vec!["label", "val"]); } #[test] @@ -1092,28 +1088,19 @@ mod tests { // empty_table: id INTEGER PRIMARY KEY, name TEXT NOT NULL // SQLite PRAGMA table_info reports notnull=0 for INTEGER PRIMARY KEY (PK does not imply // NOT NULL in SQLite's PRAGMA), and notnull=1 for the explicit NOT NULL constraint. - let mut stmt = columns(&conn, None, None, Some("empty_table"), None).unwrap(); - - let mut nullability: Vec<(String, i16)> = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - let col_name = match stmt.get_data(4, CDataType::Default).unwrap().into_owned() { - ColumnValue::String(s) => s, - other => panic!("unexpected column name value: {other:?}"), - }; - let nullable = match stmt.get_data(11, CDataType::Default).unwrap().into_owned() { - ColumnValue::I16(v) => v, - other => panic!("unexpected nullable value: {other:?}"), - }; - nullability.push((col_name, nullable)); - } + let rows = columns(&conn, None, None, Some("empty_table"), None).unwrap(); + assert_eq!(rows.len(), 2); - assert_eq!(nullability.len(), 2); - let id_nullable = nullability.iter().find(|(n, _)| n == "id").unwrap().1; - let name_nullable = nullability.iter().find(|(n, _)| n == "name").unwrap().1; + let nullable_of = |name: &str| { + rows.iter() + .find(|r| r.column_name == name) + .unwrap_or_else(|| panic!("no column {name}")) + .nullable + }; // id INTEGER PRIMARY KEY: PRAGMA notnull=0, so reported as nullable - assert_eq!(id_nullable, i16::from(Nullable::SqlNullable)); + assert_eq!(nullable_of("id"), i16::from(Nullable::SqlNullable)); // name TEXT NOT NULL: PRAGMA notnull=1, so reported as not null - assert_eq!(name_nullable, i16::from(Nullable::SqlNoNulls)); + assert_eq!(nullable_of("name"), i16::from(Nullable::SqlNoNulls)); } #[test] @@ -1121,10 +1108,10 @@ mod tests { // sqlite_type_to_sql_data_type() maps every character declared type // (including VARCHAR) to SqlDataType::EXT_W_VARCHAR, never to the bare // SqlDataType::VARCHAR, so build_column_row()'s `is_char` compares - // against EXT_W_VARCHAR. CHAR_OCTET_LENGTH (column index 15) must then - // be declared_length * BYTES_PER_CHAR for a text column, not NULL. + // against EXT_W_VARCHAR. CHAR_OCTET_LENGTH must then be + // declared_length * BYTES_PER_CHAR for a text column, not NULL. let row = build_column_row("t", "label", "VARCHAR(50)", false, 0, None); - assert_eq!(row[15], ColumnValue::I32(50 * BYTES_PER_CHAR)); + assert_eq!(row.char_octet_length, Some(50 * BYTES_PER_CHAR)); } #[test] @@ -1133,7 +1120,7 @@ mod tests { // column 16), but a BLOB's declared length is already a byte count and // must be passed through as-is, not multiplied by BYTES_PER_CHAR. let row = build_column_row("t", "data", "BLOB(50)", false, 0, None); - assert_eq!(row[15], ColumnValue::I32(50)); + assert_eq!(row.char_octet_length, Some(50)); } #[test] @@ -1141,7 +1128,7 @@ mod tests { // INTEGER is neither character nor binary data, so CHAR_OCTET_LENGTH // is NULL per the ODBC spec. let row = build_column_row("t", "id", "INTEGER", false, 0, None); - assert_eq!(row[15], ColumnValue::Null); + assert_eq!(row.char_octet_length, None); } #[test] @@ -1152,47 +1139,31 @@ mod tests { // (2_000_000_000 * 4 = 8_000_000_000). The checked multiplication // reports NULL instead of wrapping or panicking. let row = build_column_row("t", "label", "VARCHAR(2000000000)", false, 0, None); - assert_eq!(row[15], ColumnValue::Null); + assert_eq!(row.char_octet_length, None); } #[test] fn primary_keys_returns_pk_column() { let conn = setup_test_db(); - let mut stmt = primary_keys(&conn, None, None, Some("parent")).unwrap(); - - let mut pk_cols = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - // Column 3 = TABLE_NAME, Column 4 = COLUMN_NAME, Column 5 = KEY_SEQ - let table = match stmt.get_data(3, CDataType::Default).unwrap().into_owned() { - ColumnValue::String(s) => s, - other => panic!("unexpected table name: {other:?}"), - }; - let col = match stmt.get_data(4, CDataType::Default).unwrap().into_owned() { - ColumnValue::String(s) => s, - other => panic!("unexpected column name: {other:?}"), - }; - let seq = match stmt.get_data(5, CDataType::Default).unwrap().into_owned() { - ColumnValue::I16(v) => v, - other => panic!("unexpected key_seq: {other:?}"), - }; - pk_cols.push((table, col, seq)); - } - assert_eq!(pk_cols.len(), 1); - assert_eq!(pk_cols[0], ("parent".to_string(), "pk".to_string(), 1)); + let rows = primary_keys(&conn, None, None, Some("parent")).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].table_name, "parent"); + assert_eq!(rows[0].column_name, "pk"); + assert_eq!(rows[0].key_seq, 1); } #[test] fn primary_keys_no_pk_returns_empty() { let conn = setup_test_db(); // types_test has no PRIMARY KEY constraint - let mut stmt = primary_keys(&conn, None, None, Some("types_test")).unwrap(); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + let rows = primary_keys(&conn, None, None, Some("types_test")).unwrap(); + assert!(rows.is_empty()); } #[test] fn foreign_keys_by_fk_table() { let conn = setup_test_db(); - let mut stmt = foreign_keys( + let rows = foreign_keys( &conn, None, None, @@ -1203,52 +1174,26 @@ mod tests { ) .unwrap(); - let mut fks = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - // Column 3 = PKTABLE_NAME, Column 4 = PKCOLUMN_NAME - // Column 7 = FKTABLE_NAME, Column 8 = FKCOLUMN_NAME - let pk_table = match stmt.get_data(3, CDataType::Default).unwrap().into_owned() { - ColumnValue::String(s) => s, - other => panic!("unexpected pk_table: {other:?}"), - }; - let pk_col = match stmt.get_data(4, CDataType::Default).unwrap().into_owned() { - ColumnValue::String(s) => s, - other => panic!("unexpected pk_col: {other:?}"), - }; - let fk_table = match stmt.get_data(7, CDataType::Default).unwrap().into_owned() { - ColumnValue::String(s) => s, - other => panic!("unexpected fk_table: {other:?}"), - }; - let fk_col = match stmt.get_data(8, CDataType::Default).unwrap().into_owned() { - ColumnValue::String(s) => s, - other => panic!("unexpected fk_col: {other:?}"), - }; - fks.push((pk_table, pk_col, fk_table, fk_col)); - } - assert_eq!(fks.len(), 1); - assert_eq!( - fks[0], - ( - "parent".to_string(), - "pk".to_string(), - "child".to_string(), - "parent_pk".to_string(), - ) - ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].pk_table_name, "parent"); + assert_eq!(rows[0].pk_column_name, "pk"); + assert_eq!(rows[0].fk_table_name, "child"); + assert_eq!(rows[0].fk_column_name, "parent_pk"); + assert_eq!(rows[0].key_seq, 1); } #[test] fn foreign_keys_no_fk_returns_empty() { let conn = setup_test_db(); // parent has no outgoing foreign keys - let mut stmt = foreign_keys(&conn, None, None, None, None, None, Some("parent")).unwrap(); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + let rows = foreign_keys(&conn, None, None, None, None, None, Some("parent")).unwrap(); + assert!(rows.is_empty()); } #[test] fn foreign_keys_by_pk_table() { let conn = setup_test_db(); - let mut stmt = foreign_keys( + let rows = foreign_keys( &conn, None, None, @@ -1259,75 +1204,56 @@ mod tests { ) .unwrap(); - let mut count = 0; - while stmt.fetch().unwrap() == FetchResult::Row { - count += 1; - // FK should point from child.parent_pk to parent.pk - assert_eq!( - stmt.get_data(3, CDataType::Default).unwrap().into_owned(), - ColumnValue::String("parent".to_string()) - ); - assert_eq!( - stmt.get_data(7, CDataType::Default).unwrap().into_owned(), - ColumnValue::String("child".to_string()) - ); - } - assert_eq!(count, 1); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].pk_table_name, "parent"); + assert_eq!(rows[0].fk_table_name, "child"); } + /// `PKCOLUMN_NAME` is one of the columns the spec marks "not NULL", and + /// `ForeignKeyRow` enforces that. `REFERENCES parent` with no column list + /// leaves `PRAGMA foreign_key_list`'s `to` NULL, which this driver used to + /// report as a NULL `PKCOLUMN_NAME` — a value the column cannot hold. + /// SQLite defines the implicit target as the parent's primary key, so the + /// name is recovered rather than dropped. #[test] - fn tables_table_name_honors_escape_character() { - let conn = setup_test_db(); - // `empty\_table` with ESCAPE '\' means a literal underscore: matches - // exactly "empty_table". Without ESCAPE the `_` is a wildcard and the - // stray backslash matches nothing. - let mut stmt = tables(&conn, None, None, Some("empty\\_table"), None).unwrap(); - let mut names = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - if let ColumnValue::String(s) = - stmt.get_data(3, CDataType::Default).unwrap().into_owned() - { - names.push(s); - } - } - assert_eq!(names, vec!["empty_table".to_string()]); - } + fn foreign_keys_implicit_reference_resolves_the_parent_primary_key() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE p (pk INTEGER PRIMARY KEY, info TEXT); + CREATE TABLE c (id INTEGER PRIMARY KEY, p_ref INTEGER REFERENCES p);", + ) + .unwrap(); + let conn = wrap(conn); - #[test] - fn columns_column_name_is_a_like_pattern() { - let conn = setup_test_db(); - // types_test columns: id, val, label. "%l%" matches val and label. - // Under the old exact-match filter this returned zero rows. - let mut stmt = columns(&conn, None, None, Some("types_test"), Some("%l%")).unwrap(); - let mut names = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - if let ColumnValue::String(s) = - stmt.get_data(4, CDataType::Default).unwrap().into_owned() - { - names.push(s); - } - } - names.sort(); - assert_eq!(names, vec!["label".to_string(), "val".to_string()]); + let rows = foreign_keys(&conn, None, None, None, None, None, Some("c")).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].pk_table_name, "p"); + assert_eq!( + rows[0].pk_column_name, "pk", + "an implicit REFERENCES must resolve to the parent's primary key column" + ); } + /// A composite implicit reference resolves each position to the parent + /// primary key column at the same position, not always the first. #[test] - fn tables_table_type_percent_with_table_wildcard_lists_tables() { - let conn = setup_test_db(); - // TableType="%" with TableName="%" is NOT the type-discovery case (that - // requires an empty TableName): it must list actual tables/views. - let mut stmt = tables(&conn, Some(""), Some(""), Some("%"), Some("%")).unwrap(); - let mut names = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - if let ColumnValue::String(s) = - stmt.get_data(3, CDataType::Default).unwrap().into_owned() - { - names.push(s); - } - } - assert!( - names.contains(&"types_test".to_string()), - "expected real table listing, got {names:?}" + fn foreign_keys_implicit_composite_reference_resolves_per_position() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE p (a INTEGER, b INTEGER, PRIMARY KEY (a, b)); + CREATE TABLE c (x INTEGER, y INTEGER, FOREIGN KEY (x, y) REFERENCES p);", + ) + .unwrap(); + let conn = wrap(conn); + + let mut rows = foreign_keys(&conn, None, None, None, None, None, Some("c")).unwrap(); + rows.sort_by_key(|r| r.key_seq); + assert_eq!(rows.len(), 2); + assert_eq!( + rows.iter() + .map(|r| (r.fk_column_name.as_str(), r.pk_column_name.as_str())) + .collect::<Vec<_>>(), + vec![("x", "a"), ("y", "b")] ); } @@ -1339,87 +1265,67 @@ mod tests { CREATE INDEX ix_t_bc ON t(b, c DESC);", ) .unwrap(); - SqliteConnection { - conn: Mutex::new(conn), - manual_commit: std::sync::atomic::AtomicBool::new(false), - } - } - - // column ordinals in the 13-column SQLStatistics result set (1-based get_data) - const NON_UNIQUE: u16 = 4; - const TYPE_COL: u16 = 7; - const ORDINAL_POSITION: u16 = 8; - const COLUMN_NAME: u16 = 9; - const ASC_OR_DESC: u16 = 10; - const FILTER_CONDITION: u16 = 13; - - /// (TYPE, NON_UNIQUE, COLUMN_NAME, ORDINAL_POSITION, ASC_OR_DESC) subset of - /// each fetched row, in the order `get_data` is called below. - fn collect_stats( - stmt: &mut SqliteStatement, - ) -> Vec<( - ColumnValue, - ColumnValue, - ColumnValue, - ColumnValue, - ColumnValue, - )> { - let mut out = Vec::new(); - while stmt.fetch().unwrap() == FetchResult::Row { - out.push(( - stmt.get_data(TYPE_COL, CDataType::Default) - .unwrap() - .into_owned(), - stmt.get_data(NON_UNIQUE, CDataType::Default) - .unwrap() - .into_owned(), - stmt.get_data(COLUMN_NAME, CDataType::Default) - .unwrap() - .into_owned(), - stmt.get_data(ORDINAL_POSITION, CDataType::Default) - .unwrap() - .into_owned(), - stmt.get_data(ASC_OR_DESC, CDataType::Default) - .unwrap() - .into_owned(), - )); - } - out + wrap(conn) } + /// The rows [`statistics`] produces, keyed by index name and column so the + /// assertions do not depend on an order core owns. #[test] - fn statistics_reports_table_stat_row_and_indexes_in_order() { + fn statistics_reports_a_table_stat_row_and_one_row_per_index_key_column() { let conn = setup_stats_db(); - let mut stmt = statistics(&conn, None, None, Some("t"), false).unwrap(); - assert_eq!(stmt.column_count(), 13); - let rows = collect_stats(&mut stmt); - // Row 0: table-stat row (TYPE = SQL_TABLE_STAT, NON_UNIQUE NULL, COLUMN_NAME NULL). - assert_eq!(rows[0].0, ColumnValue::I16(SQL_TABLE_STAT)); - assert_eq!(rows[0].1, ColumnValue::Null); - assert_eq!(rows[0].2, ColumnValue::Null); - // Next: the UNIQUE index (NON_UNIQUE = SQL_FALSE = 0) before the non-unique one. - assert_eq!(rows[1].0, ColumnValue::I16(SQL_INDEX_OTHER)); - assert_eq!(rows[1].1, ColumnValue::I16(SQL_FALSE as i16)); - assert_eq!(rows[1].2, ColumnValue::String("a".into())); - // Then the non-unique composite index (b, c DESC): 2 rows, NON_UNIQUE = SQL_TRUE = 1. - assert_eq!(rows[2].1, ColumnValue::I16(1)); - assert_eq!(rows[2].2, ColumnValue::String("b".into())); - assert_eq!(rows[2].3, ColumnValue::I16(1)); // ORDINAL_POSITION - assert_eq!(rows[2].4, ColumnValue::String("A".into())); - assert_eq!(rows[3].2, ColumnValue::String("c".into())); - assert_eq!(rows[3].3, ColumnValue::I16(2)); - assert_eq!(rows[3].4, ColumnValue::String("D".into())); // c DESC + let rows = statistics(&conn, None, None, Some("t"), false).unwrap(); + + let stat_rows: Vec<&StatisticsRow> = rows + .iter() + .filter(|r| r.index_type == SQL_TABLE_STAT) + .collect(); + assert_eq!(stat_rows.len(), 1, "exactly one table-stat row"); + assert_eq!(stat_rows[0].table_name, "t"); + // The table-stat row's NULL NON_UNIQUE is what puts it first once core + // sorts, given this driver's SQL_NC_LOW null collation. + assert_eq!(stat_rows[0].non_unique, None); + assert_eq!(stat_rows[0].column_name, None); + + let index_row = |index: &str, column: &str| { + rows.iter() + .find(|r| { + r.index_name.as_deref() == Some(index) + && r.column_name.as_deref() == Some(column) + }) + .unwrap_or_else(|| panic!("no row for {index}.{column}")) + }; + + let unique = index_row("ux_t_a", "a"); + assert_eq!(unique.index_type, SQL_INDEX_OTHER); + assert_eq!(unique.non_unique, Some(SQL_FALSE as i16)); + assert_eq!(unique.ordinal_position, Some(1)); + + // The non-unique composite index (b, c DESC). + let b = index_row("ix_t_bc", "b"); + assert_eq!(b.non_unique, Some(1)); + assert_eq!(b.ordinal_position, Some(1)); + assert_eq!(b.asc_or_desc.as_deref(), Some("A")); + + let c = index_row("ix_t_bc", "c"); + assert_eq!(c.ordinal_position, Some(2)); + assert_eq!(c.asc_or_desc.as_deref(), Some("D")); } #[test] fn statistics_unique_only_drops_non_unique_indexes() { let conn = setup_stats_db(); - let mut stmt = statistics(&conn, None, None, Some("t"), true).unwrap(); - let rows = collect_stats(&mut stmt); + let rows = statistics(&conn, None, None, Some("t"), true).unwrap(); // table-stat row + the unique index's single column only. assert_eq!(rows.len(), 2); - assert_eq!(rows[0].0, ColumnValue::I16(SQL_TABLE_STAT)); - assert_eq!(rows[1].2, ColumnValue::String("a".into())); + assert_eq!( + rows.iter() + .filter(|r| r.index_type == SQL_TABLE_STAT) + .count(), + 1 + ); + assert!(rows.iter().any( + |r| r.column_name.as_deref() == Some("a") && r.non_unique == Some(SQL_FALSE as i16) + )); } #[test] @@ -1430,82 +1336,59 @@ mod tests { .unwrap() .execute_batch("CREATE TABLE plain (x INTEGER);") .unwrap(); - let mut stmt = statistics(&conn, None, None, Some("plain"), false).unwrap(); - let rows = collect_stats(&mut stmt); + let rows = statistics(&conn, None, None, Some("plain"), false).unwrap(); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].0, ColumnValue::I16(SQL_TABLE_STAT)); + assert_eq!(rows[0].index_type, SQL_TABLE_STAT); } #[test] fn statistics_with_no_table_returns_empty() { let conn = setup_stats_db(); - let mut stmt = statistics(&conn, None, None, None, false).unwrap(); - assert_eq!(stmt.column_count(), 13); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + assert!( + statistics(&conn, None, None, None, false) + .unwrap() + .is_empty() + ); } - fn setup_partial_index_db() -> SqliteConnection { + #[test] + fn statistics_partial_index_reports_empty_filter_condition() { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE tp (a INTEGER, b TEXT); CREATE INDEX ix_tp_partial ON tp(a) WHERE a > 0;", ) .unwrap(); - SqliteConnection { - conn: Mutex::new(conn), - manual_commit: std::sync::atomic::AtomicBool::new(false), - } - } + let conn = wrap(conn); - #[test] - fn statistics_partial_index_reports_empty_filter_condition() { - let conn = setup_partial_index_db(); - let mut stmt = statistics(&conn, None, None, Some("tp"), false).unwrap(); + let rows = statistics(&conn, None, None, Some("tp"), false).unwrap(); // table-stat row + a single index-column row: exactly one index. - assert_eq!(stmt.column_count(), 13); - assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); - // Row 0: table-stat row; skip it. - assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); - // Row 1: the partial index's single key column. - assert_eq!( - stmt.get_data(FILTER_CONDITION, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::String(String::new()) - ); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + assert_eq!(rows.len(), 2); + let index = rows + .iter() + .find(|r| r.index_type == SQL_INDEX_OTHER) + .expect("the partial index's key column"); + assert_eq!(index.filter_condition.as_deref(), Some("")); } - fn setup_expression_index_db() -> SqliteConnection { + #[test] + fn statistics_expression_index_reports_empty_column_name() { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE te (a INTEGER, b INTEGER); CREATE INDEX ix_te_expr ON te(a + b);", ) .unwrap(); - SqliteConnection { - conn: Mutex::new(conn), - manual_commit: std::sync::atomic::AtomicBool::new(false), - } - } + let conn = wrap(conn); - #[test] - fn statistics_expression_index_reports_empty_column_name() { - let conn = setup_expression_index_db(); - let mut stmt = statistics(&conn, None, None, Some("te"), false).unwrap(); + let rows = statistics(&conn, None, None, Some("te"), false).unwrap(); // table-stat row + a single index-column row: exactly one index. - assert_eq!(stmt.column_count(), 13); - assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); - // Row 0: table-stat row; skip it. - assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); - // Row 1: the expression index's key column (key=1, name=NULL). - assert_eq!( - stmt.get_data(COLUMN_NAME, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::String(String::new()) - ); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + assert_eq!(rows.len(), 2); + let index = rows + .iter() + .find(|r| r.index_type == SQL_INDEX_OTHER) + .expect("the expression index's key column (key=1, name=NULL)"); + assert_eq!(index.column_name.as_deref(), Some("")); } fn setup_specialcols_db() -> SqliteConnection { @@ -1516,22 +1399,13 @@ mod tests { CREATE TABLE without_rowid (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;", ) .unwrap(); - SqliteConnection { - conn: Mutex::new(conn), - manual_commit: std::sync::atomic::AtomicBool::new(false), - } + wrap(conn) } - const SC_SCOPE: u16 = 1; - const SC_COLUMN_NAME: u16 = 2; - const SC_DATA_TYPE: u16 = 3; - const SC_BUFFER_LENGTH: u16 = 6; - const SC_PSEUDO_COLUMN: u16 = 8; - #[test] fn special_columns_integer_pk_is_reported_as_real_column() { let conn = setup_specialcols_db(); - let mut stmt = special_columns( + let rows = special_columns( &conn, IdentifierType::BestRowId, None, @@ -1541,43 +1415,22 @@ mod tests { Nullable::SqlNullable, ) .unwrap(); - assert_eq!(stmt.column_count(), 8); - assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); - assert_eq!( - stmt.get_data(SC_COLUMN_NAME, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::String("id".into()) - ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].column_name, "id"); // A declared INTEGER PRIMARY KEY is the 8-byte 64-bit rowid alias, not // a plain INTEGER column: DATA_TYPE must be SQL_BIGINT and // BUFFER_LENGTH must be 8 (not the 19-byte COLUMN_SIZE-derived value // a generic INTEGER column would get). - assert_eq!( - stmt.get_data(SC_DATA_TYPE, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::I16(SqlDataType::EXT_BIG_INT.0) - ); - assert_eq!( - stmt.get_data(SC_BUFFER_LENGTH, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::I32(8) - ); - assert_eq!( - stmt.get_data(SC_PSEUDO_COLUMN, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::I16(SQL_PC_NOT_PSEUDO) - ); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + assert_eq!(rows[0].data_type, SqlDataType::EXT_BIG_INT.0); + assert_eq!(rows[0].buffer_length, Some(8)); + assert_eq!(rows[0].pseudo_column, Some(SQL_PC_NOT_PSEUDO)); } #[test] fn special_columns_rowid_table_reports_rowid_pseudo_column() { let conn = setup_specialcols_db(); - let mut stmt = special_columns( + let rows = special_columns( &conn, IdentifierType::BestRowId, None, @@ -1587,32 +1440,18 @@ mod tests { Nullable::SqlNullable, ) .unwrap(); - assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); - assert_eq!( - stmt.get_data(SC_COLUMN_NAME, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::String("rowid".into()) - ); - assert_eq!( - stmt.get_data(SC_PSEUDO_COLUMN, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::I16(SQL_PC_PSEUDO) - ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].column_name, "rowid"); + assert_eq!(rows[0].pseudo_column, Some(SQL_PC_PSEUDO)); // The volatile rowid pseudo-column only guarantees TRANSACTION scope. - assert_eq!( - stmt.get_data(SC_SCOPE, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::I16(Scope::Transaction.into()) - ); + assert_eq!(rows[0].scope, Some(Scope::Transaction.into())); } #[test] fn special_columns_without_rowid_reports_pk_columns() { let conn = setup_specialcols_db(); - let mut stmt = special_columns( + let rows = special_columns( &conn, IdentifierType::BestRowId, None, @@ -1622,36 +1461,28 @@ mod tests { Nullable::SqlNullable, ) .unwrap(); - assert_eq!(stmt.fetch().unwrap(), FetchResult::Row); - assert_eq!( - stmt.get_data(SC_COLUMN_NAME, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::String("k".into()) - ); - assert_eq!( - stmt.get_data(SC_PSEUDO_COLUMN, CDataType::Default) - .unwrap() - .into_owned(), - ColumnValue::I16(SQL_PC_NOT_PSEUDO) - ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].column_name, "k"); + assert_eq!(rows[0].pseudo_column, Some(SQL_PC_NOT_PSEUDO)); } #[test] fn special_columns_rowver_is_empty() { let conn = setup_specialcols_db(); - let mut stmt = special_columns( - &conn, - IdentifierType::RowVer, - None, - None, - Some("with_int_pk"), - Scope::CurRow, - Nullable::SqlNullable, - ) - .unwrap(); - assert_eq!(stmt.column_count(), 8); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + assert!( + special_columns( + &conn, + IdentifierType::RowVer, + None, + None, + Some("with_int_pk"), + Scope::CurRow, + Nullable::SqlNullable, + ) + .unwrap() + .is_empty() + ); } #[test] @@ -1659,16 +1490,18 @@ mod tests { // The rowid pseudo-column only guarantees TRANSACTION scope; a request for // SESSION cannot be met, so the result set is empty (per spec). let conn = setup_specialcols_db(); - let mut stmt = special_columns( - &conn, - IdentifierType::BestRowId, - None, - None, - Some("no_pk"), - Scope::Session, - Nullable::SqlNullable, - ) - .unwrap(); - assert_eq!(stmt.fetch().unwrap(), FetchResult::NoData); + assert!( + special_columns( + &conn, + IdentifierType::BestRowId, + None, + None, + Some("no_pk"), + Scope::Session, + Nullable::SqlNullable, + ) + .unwrap() + .is_empty() + ); } } diff --git a/src/escape_dialect.rs b/src/escape_dialect.rs index 9eb0ef9..263d0b4 100644 --- a/src/escape_dialect.rs +++ b/src/escape_dialect.rs @@ -243,9 +243,9 @@ mod tests { #[test] fn identifier_quotes_include_brackets_and_backticks() { let d = dialect(); - assert!(d.identifier_quotes.contains(&('[', ']'))); - assert!(d.identifier_quotes.contains(&('`', '`'))); - assert!(d.identifier_quotes.contains(&('"', '"'))); + assert!(d.identifier_quotes().contains(&('[', ']'))); + assert!(d.identifier_quotes().contains(&('`', '`'))); + assert!(d.identifier_quotes().contains(&('"', '"'))); } #[test] diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 2718956..084a8cd 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -14,9 +14,9 @@ use stackable_odbc_core::{ AttrOdbcVersion, CDataType, CompletionType, ConnectionAttribute, Desc, EnvironmentAttribute, HandleType, HeaderDiagnosticIdentifier, InfoType, Nullable, Numeric, ParamType, SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, SQL_CASCADE, SQL_CD_FALSE, - SQL_CURSOR_FORWARD_ONLY, SQL_DIAG_MESSAGE_TEXT, SQL_DRIVER_ODBC_VER_STRING, - SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_SENSITIVE, SQL_INDEX_UNIQUE, - SQL_QUICK, SQL_RESTRICT, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, + SQL_CURSOR_FORWARD_ONLY, SQL_DRIVER_ODBC_VER_STRING, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, + SQL_GD_BOUND, SQL_IC_SENSITIVE, SQL_INDEX_ALL, SQL_INDEX_OTHER, SQL_INDEX_UNIQUE, + SQL_QUICK, SQL_RESTRICT, SQL_TABLE_STAT, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, SqlReturn, StatementAttribute, Timestamp, expected_kind, }, @@ -904,6 +904,142 @@ fn sql_tables_w_returns_tables_and_views() { } } +/// `SQL_ALL_CATALOGS`, `SQL_ALL_SCHEMAS` and `SQL_ALL_TABLE_TYPES` are all the +/// same sentinel — `"%"` — distinguished by which argument carries it while +/// the other two are *empty strings*. Core detects and serves all three; this +/// pins what an application actually receives from this driver. +const SQL_ALL_SENTINEL: &str = "%"; + +/// Drive `SQLTablesW` with the three name arguments and the table-type +/// argument given as explicit-length strings, and collect one column. +unsafe fn tables_column( + stmt: *mut c_void, + catalog: &str, + schema: &str, + table: &str, + table_type: &str, + col: u16, +) -> Vec<String> { + let cat: Vec<u16> = catalog.encode_utf16().collect(); + let sch: Vec<u16> = schema.encode_utf16().collect(); + let tab: Vec<u16> = table.encode_utf16().collect(); + let tt: Vec<u16> = table_type.encode_utf16().collect(); + // An empty `Vec<u16>`'s `as_ptr()` is dangling; a real buffer plus a length + // of 0 is what makes an argument an empty *string* rather than a null + // pointer, which is the whole distinction the enumerations turn on. + let backing: [u16; 1] = [0]; + let ptr = |v: &Vec<u16>| { + if v.is_empty() { + backing.as_ptr() + } else { + v.as_ptr() + } + }; + + let ret = unsafe { + ffi::metadata::sql_tables_w::<SqliteBackend>( + stmt, + ptr(&cat), + cat.len() as i16, + ptr(&sch), + sch.len() as i16, + ptr(&tab), + tab.len() as i16, + ptr(&tt), + tt.len() as i16, + ) + }; + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut out = Vec::new(); + loop { + let ret = unsafe { ffi::fetch::sql_fetch::<SqliteBackend>(stmt) }; + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + out.push(unsafe { fetch_string_col(stmt, col) }); + } + // Reaching SQL_NO_DATA does not close the cursor; leaving it open would + // make the next call on this handle 24000, so callers can reuse the handle. + assert_eq!( + unsafe { ffi::cursor::sql_close_cursor::<SqliteBackend>(stmt) }, + SqlReturn::SUCCESS + ); + out +} + +/// `SQL_ALL_TABLE_TYPES` lists what `SqliteBackend::table_types` declares. +/// +/// This is how a BI tool's navigator populates its type filter. It used to be +/// answered by this driver's own `metadata::tables`; core serves it now, from +/// the `table_types` hook, so this test is what keeps the reported list tied +/// to what `SQLTables` can actually return in `TABLE_TYPE`. +#[test] +fn sql_tables_w_all_table_types_lists_table_and_view() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + // TABLE_TYPE is column 4. + let mut types = tables_column(stmt, "", "", "", SQL_ALL_SENTINEL, 4); + types.sort(); + assert_eq!(types, vec!["TABLE".to_string(), "VIEW".to_string()]); + + cleanup(env, conn, stmt); + } +} + +/// `SQL_ALL_CATALOGS` and `SQL_ALL_SCHEMAS` are empty result sets. +/// +/// Core answers both without consulting the backend, because +/// `supports_catalogs` and `supports_schemas` already say SQLite has neither — +/// which is why this driver implements neither `catalogs` nor `schemas`. +#[test] +fn sql_tables_w_all_catalogs_and_all_schemas_are_empty() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + // TABLE_CAT is column 1, TABLE_SCHEM column 2. + assert!(tables_column(stmt, SQL_ALL_SENTINEL, "", "", "", 1).is_empty()); + assert!(tables_column(stmt, "", SQL_ALL_SENTINEL, "", "", 2).is_empty()); + + cleanup(env, conn, stmt); + } +} + +/// `"%"` in every argument is an ordinary match-everything query, not an +/// enumeration — the sentinel only triggers when the *other* arguments are +/// empty strings. A detector keyed on `"%"` alone would answer this with a +/// catalog list instead of the data source's tables. +#[test] +fn sql_tables_w_percent_everywhere_is_an_ordinary_query() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + // TABLE_NAME is column 3. + let names = tables_column( + stmt, + SQL_ALL_SENTINEL, + SQL_ALL_SENTINEL, + SQL_ALL_SENTINEL, + SQL_ALL_SENTINEL, + 3, + ); + assert!( + names.contains(&"test_table".to_string()), + "expected a real table listing, got {names:?}" + ); + + cleanup(env, conn, stmt); + } +} + #[test] fn sql_tables_w_with_type_filter() { unsafe { @@ -1582,30 +1718,81 @@ fn get_cursor_type_default_is_forward_only() { } } +/// `SQL_ATTR_QUERY_TIMEOUT`'s "no timeout" value, and the only one this driver +/// can honour. Core has the same constant privately; this names the value the +/// test asks about rather than passing a bare `0`. +const SQL_QUERY_TIMEOUT_DEFAULT: usize = 0; + +/// A requested timeout other than "no timeout" is substituted, not stored. #[test] -fn set_query_timeout_stored_and_retrieved() { +fn set_query_timeout_is_substituted_with_no_timeout() { + // `Backend` is synchronous and this driver implements no cancellation, so + // no deadline is ever applied to a running statement. `SQL_ATTR_QUERY_TIMEOUT` + // is on the spec's 01S02 substitution list for exactly this case: the value + // is replaced with `SQL_QUERY_TIMEOUT_DEFAULT` and reported as + // SQL_SUCCESS_WITH_INFO, so an application that asks for 30 seconds can see + // it did not get them by reading the attribute back. This previously + // returned SUCCESS and echoed 30, confirming a deadline nothing enforced. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - let _ = ffi::stmt_attr::sql_set_stmt_attr_w::<SqliteBackend>( - stmt, - StatementAttribute::QueryTimeout as i32, - 30usize as *mut std::ffi::c_void, - 0, + const REQUESTED_TIMEOUT_SECONDS: usize = 30; + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::<SqliteBackend>( + stmt, + StatementAttribute::QueryTimeout as i32, + std::ptr::without_provenance_mut(REQUESTED_TIMEOUT_SECONDS), + 0, + ), + SqlReturn::SUCCESS_WITH_INFO, + "an unsupported query timeout is substituted, not refused" ); - let mut val: u32 = 0; + assert_eq!( + last_sqlstate(stmt), + stackable_odbc_core::types::sql_state::OPTION_VALUE_CHANGED + ); + + // `SQL_ATTR_QUERY_TIMEOUT` is a SQLUINTEGER attribute, so the driver + // writes exactly four bytes here whatever the buffer's width. + let mut val: u32 = u32::MAX; + let mut len: i32 = 0; assert_eq!( ffi::stmt_attr::sql_get_stmt_attr_w::<SqliteBackend>( stmt, - 0, - &mut val as *mut u32 as *mut std::ffi::c_void, - 0, - std::ptr::null_mut(), + StatementAttribute::QueryTimeout as i32, + &raw mut val as *mut std::ffi::c_void, + std::mem::size_of::<u32>() as i32, + &mut len, ), SqlReturn::SUCCESS ); - assert_eq!(val, 30); + assert_eq!( + val as usize, SQL_QUERY_TIMEOUT_DEFAULT, + "the substituted value has to be what the application reads back" + ); + + cleanup(env, conn, stmt); + } +} + +/// Asking for the value the driver can honour is a plain success. +#[test] +fn set_query_timeout_to_no_timeout_succeeds_without_substitution() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::<SqliteBackend>( + stmt, + StatementAttribute::QueryTimeout as i32, + std::ptr::without_provenance_mut(SQL_QUERY_TIMEOUT_DEFAULT), + 0, + ), + SqlReturn::SUCCESS, + "no timeout is what this driver does, so there is nothing to substitute" + ); cleanup(env, conn, stmt); } @@ -2349,6 +2536,103 @@ fn sql_cancel_with_open_cursor_does_not_close_it() { } } +/// A statement handle carried to another thread so `SQLCancel` can be called +/// on it while the first thread executes — the cross-thread case the spec +/// singles out, and the only one where cancellation has anything to do. +/// +/// Sound because nothing here dereferences the pointer: it is the opaque token +/// an application holds, which every core entry point validates through its +/// own registry. Core is built for exactly this — `sql_cancel` clones the +/// backend's token out of the registry before touching anything else, so the +/// handle staying valid is core's problem, not this test's. +struct SendStmt(*mut c_void); +// SAFETY: see the type's doc comment. The pointer is only ever passed back to +// core, never read through. +unsafe impl Send for SendStmt {} + +/// `SQLCancel` from another thread stops a running statement with `HY008`. +/// +/// This is what `Backend::cancel` buys: `sqlite3_interrupt` makes the +/// in-flight `sqlite3_step` return `SQLITE_INTERRUPT`, which `map_sqlite_error` +/// classifies as the spec's "operation canceled". Without it the call below +/// would run to completion and report `SQL_SUCCESS`. +#[test] +fn sql_cancel_from_another_thread_stops_a_running_statement() { + use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }; + + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + let stop = Arc::new(AtomicBool::new(false)); + // Held across each `SQLCancel` so the main thread can be sure no cancel + // is in flight before it reads the diagnostic. It matters because + // `SQLCancel`'s *idle* branch clears the statement's diagnostic queue — + // correctly, since a cancelled statement may be re-executed — so a + // cancel landing after `SQLExecDirectW` returned would wipe the very + // `HY008` this test is looking for. + let gate = Arc::new(Mutex::new(())); + + let handle = SendStmt(stmt); + let canceller = { + let (stop, gate) = (Arc::clone(&stop), Arc::clone(&gate)); + std::thread::spawn(move || { + let handle = handle; + while !stop.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(20)); + let _held = gate.lock().expect("gate"); + if stop.load(Ordering::SeqCst) { + break; + } + // Interrupt repeatedly rather than once: SQLite documents an + // interrupt raised while nothing is running as a no-op that + // "has no effect on SQL statements that are started after + // the sqlite3_interrupt() call returns", so a single + // well-timed call would be a race. Each retry is harmless. + // SAFETY: `handle.0` is a live statement handle; the main + // thread outlives this one and frees it only after `join`. + // (The enclosing `unsafe` block already covers this.) + let ret = ffi::cursor::sql_cancel::<SqliteBackend>(handle.0); + assert_eq!(ret, SqlReturn::SUCCESS); + } + }) + }; + + // A recursive CTE with far more iterations than the cancelling thread + // needs. Bounded rather than infinite on purpose: if the interrupt + // never lands this finishes and fails the assertion below, instead of + // hanging the suite. + let ret = exec_direct( + stmt, + "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 50000000) \ + SELECT count(*) FROM c", + ); + + stop.store(true, Ordering::SeqCst); + // Acquiring the gate after setting `stop` is the synchronisation point: + // any in-flight cancel finishes first, and the canceller re-checks + // `stop` under the same gate, so none can start while the diagnostic is + // being read. + let held = gate.lock().expect("gate"); + assert_eq!( + ret, + SqlReturn::ERROR, + "the running statement must be stopped, not run to completion" + ); + assert_eq!( + last_sqlstate(stmt), + crate::backend::SQL_STATE_OPERATION_CANCELED + ); + drop(held); + canceller.join().expect("canceller thread"); + + cleanup(env, conn, stmt); + } +} + // --- SQLStatisticsW integration tests --- #[test] @@ -2396,8 +2680,94 @@ fn sql_statistics_w_returns_table_stat_row() { } } +/// The spec's `SQLStatistics` order, as an application sees it. +/// +/// Sorting moved to core, which orders by NON_UNIQUE, TYPE, INDEX_QUALIFIER, +/// INDEX_NAME, ORDINAL_POSITION. The table-stat row leads only because its +/// NON_UNIQUE is NULL and this driver reports `SQL_NC_LOW` for +/// `SQL_NULL_COLLATION` — core takes NULL placement from that hook rather than +/// choosing for itself, so this is the test that ties the two together. It has +/// to run through the FFI: the backend now returns rows unsorted. #[test] -fn sql_statistics_w_no_table_filter_also_succeeds() { +fn sql_statistics_w_orders_table_stat_row_first_then_unique_before_non_unique() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_sql( + conn, + "CREATE TABLE idx_t (a INTEGER, b TEXT, c REAL); + CREATE INDEX ix_bc ON idx_t(b, c); + CREATE UNIQUE INDEX ux_a ON idx_t(a);", + ); + + let table = "idx_t"; + let table_wide: Vec<u16> = table.encode_utf16().collect(); + assert_eq!( + ffi::metadata::sql_statistics_w::<SqliteBackend>( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + table_wide.as_ptr(), + table_wide.len() as i16, + SQL_INDEX_ALL, + SQL_QUICK, + ), + SqlReturn::SUCCESS + ); + + // TYPE is column 7, COLUMN_NAME column 9, ORDINAL_POSITION column 8. + let mut seen: Vec<(i16, String, i16)> = Vec::new(); + loop { + let ret = ffi::fetch::sql_fetch::<SqliteBackend>(stmt); + if ret == SqlReturn::NO_DATA { + break; + } + assert_eq!(ret, SqlReturn::SUCCESS); + let index_type = fetch_i16_col(stmt, 7); + // COLUMN_NAME is NULL on the table-stat row; read it only for the + // index rows, where the driver always supplies a string. + let column = if index_type == SQL_TABLE_STAT { + String::new() + } else { + fetch_string_col(stmt, 9) + }; + let ordinal = if index_type == SQL_TABLE_STAT { + 0 + } else { + fetch_i16_col(stmt, 8) + }; + seen.push((index_type, column, ordinal)); + } + + assert_eq!( + seen, + vec![ + // The table-stat row: NON_UNIQUE is NULL, which SQL_NC_LOW puts first. + (SQL_TABLE_STAT, String::new(), 0), + // The unique index (NON_UNIQUE = SQL_FALSE = 0) before the non-unique one. + (SQL_INDEX_OTHER, "a".to_string(), 1), + // Then the non-unique composite index, in key order. + (SQL_INDEX_OTHER, "b".to_string(), 1), + (SQL_INDEX_OTHER, "c".to_string(), 2), + ] + ); + + cleanup(env, conn, stmt); + } +} + +/// A null `TableName` is `HY009`, not an empty result set. +/// +/// `SQLStatistics` is one of only two catalog functions whose "the *TableName* +/// argument was a null pointer" clause carries no **(DM)** marker, so the +/// driver owns it rather than the Driver Manager — and a table this function +/// describes indexes of is not optional. This previously returned +/// `SQL_SUCCESS` with no rows, which an application reads as "that table has +/// no indexes". +#[test] +fn sql_statistics_w_null_table_name_is_rejected() { unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -2414,7 +2784,45 @@ fn sql_statistics_w_no_table_filter_also_succeeds() { 0, 0, ); + assert_eq!(ret, SqlReturn::ERROR); + assert_eq!( + last_sqlstate(stmt), + stackable_odbc_core::types::sql_state::INVALID_USE_OF_NULL_POINTER + ); + + cleanup(env, conn, stmt); + } +} + +/// An empty-string `TableName` is a legal ordinary argument that names no +/// table, which is an empty result set rather than an error. +#[test] +fn sql_statistics_w_empty_table_name_returns_no_rows() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + setup_metadata_tables(conn); + + // A real, non-dangling pointer with length 0: an empty `Vec<u16>`'s + // `as_ptr()` is dangling, which is what makes this an empty *string* + // rather than a null pointer. + let empty: [u16; 1] = [0]; + let ret = ffi::metadata::sql_statistics_w::<SqliteBackend>( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + empty.as_ptr(), + 0, + 0, + 0, + ); assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<SqliteBackend>(stmt), + SqlReturn::NO_DATA + ); cleanup(env, conn, stmt); } @@ -2930,7 +3338,7 @@ fn get_diag_field_native_error_after_error() { #[test] fn get_diag_field_message_text_after_error() { - // SQL_DIAG_MESSAGE_TEXT (6) returns the diagnostic message string. + // SQL_DIAG_MESSAGE_TEXT returns the diagnostic message string. // After an invalid-SQL error the message must be non-empty. unsafe { let (env, conn, stmt) = alloc_handles(); @@ -2946,7 +3354,7 @@ fn get_diag_field_message_text_after_error() { HandleType::Stmt as i16, stmt, 1, // first record - SQL_DIAG_MESSAGE_TEXT, + HeaderDiagnosticIdentifier::MessageText as i16, msg_buf.as_mut_ptr() as *mut c_void, buffer_length, &mut str_len, @@ -2990,7 +3398,7 @@ fn get_diag_field_message_text_long_message_does_not_panic() { HandleType::Stmt as i16, stmt, 1, - SQL_DIAG_MESSAGE_TEXT, + HeaderDiagnosticIdentifier::MessageText as i16, msg_buf.as_mut_ptr() as *mut c_void, buffer_length, &mut str_len, @@ -3539,7 +3947,7 @@ fn bulk_operations_returns_hyc00() { // SQLBulkOperations is not supported by this driver. It must return ERROR // with SQLSTATE HYC00 (optional feature not implemented) even when a cursor // is open. - use stackable_odbc_core::types::SQL_ADD; + use stackable_odbc_core::odbc_sys::BulkOperation; unsafe { let (env, conn, stmt) = alloc_handles(); @@ -3555,7 +3963,8 @@ fn bulk_operations_returns_hyc00() { SqlReturn::SUCCESS ); - let ret = ffi::cursor::sql_bulk_operations::<SqliteBackend>(stmt, SQL_ADD); + let ret = + ffi::cursor::sql_bulk_operations::<SqliteBackend>(stmt, BulkOperation::Add as i16); assert_eq!(ret, SqlReturn::ERROR); cleanup(env, conn, stmt); From 9d6b2eebad9f936045cd174caab2f6fe1678171f Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sat, 1 Aug 2026 22:38:51 +0200 Subject: [PATCH 28/50] feat!: adapt to core's catalog query types and sealed rows, and correct four info values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up the previous commit deferred, plus everything else core moved since. Three breaking changes with no compiling intermediate state, so they land together. Catalog query types. The six catalog methods take a sealed query object instead of five to eight positional arguments — `SQLForeignKeys` alone took six `Option<&str>` in a row, where crossing a primary-key argument with its foreign-key counterpart compiled without complaint. The query travels all the way into `metadata.rs` rather than being unpacked at the trait boundary, which would reintroduce that hazard one layer down. `TablesQuery::table_types()` is a `&[String]` core has already split on commas and stripped the quotes from, so `metadata::tables` loses its own parsing; a lone "%" still arrives, because the `SQL_ALL_TABLE_TYPES` enumeration core answers itself additionally requires the other three arguments to be empty strings, and is still read as no filter. Sealed rows. Every catalog row type is `#[non_exhaustive]`, so the eight struct literals become `Default` plus the consuming setter per column. A column this driver does not populate is now unnamed rather than spelled `None`, which is the point — it makes a column added to a spec result set a core-only change — so each site says in a comment which columns it leaves NULL and why. Ten new required capability hooks. Six are values this driver already stated and that now move out of `sqlite_get_info` into the hook, because answering in both places is the "declare it once" violation AGENTS.md describes: `driver_name`, `driver_version`, `dbms_name`, `dbms_version`, `integrity` and `txn_capable`. The snapshot pins all six regardless of which layer answers, which is what made moving them safe. Two more were already pinned at core's default and are now claims this driver makes on purpose: `accessible_procedures` "N" and `txn_capable` SQL_TC_DML. `driver_name`/`driver_version` take no connection — the Windows DM asks for driver identity before `SQLDriverConnectW` — while `dbms_name`/`dbms_version` describe what was connected to and take one. Two info values were wrong, and both are now live-probed rather than read off the documentation. `SQL_QUOTED_IDENTIFIER_CASE` claimed SQL_IC_SENSITIVE, telling an application that "T" and "t" are different tables; in SQLite double quotes are a delimiter, not a case-sensitivity switch, so it is SQL_IC_MIXED, and the probe asserts both halves of that — case-insensitive matching and mixed-case storage. `SQL_SPECIAL_CHARACTERS` claimed "", which was core's old default rather than a claim this driver ever made; SQLite parses `$` in an undelimited identifier, so it is "$", probed over 31 candidates with the rejected ones asserted too. The negative half is what stops it understating again, the same lesson `alter_table_capabilities_are_each_live_probed` records. SQLRowCount. Core now reads a zero-column statement reporting `Some(0)` as SQL_NO_DATA, per SQLExecDirect's Comments, which surfaced that this driver answered `Some(0)` for DDL — so every `CREATE TABLE` it ran returned SQL_NO_DATA to the application, and 60 of the 66 initial test failures were that. `row_count` now distinguishes "counted zero" from "no count applies". SQLite exposes no predicate for this (`sqlite3_stmt_readonly` is false for DDL too), so `is_searched_dml` decides from the leading keyword, past whitespace and both comment forms, counting REPLACE and WITH alongside the obvious three. That also removes a stale count: `sqlite3_changes()` reports the most recently completed INSERT, UPDATE or DELETE, so a `CREATE TABLE` run after a three-row INSERT was handed that 3 and reported it. Two values changed underneath us, both core-owned and both describing core's fetch path rather than SQLite, so the snapshot follows: `SQL_CURSOR_SENSITIVITY` to SQL_UNSPECIFIED, and `SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES2` to SQL_CA2_READ_ONLY_CONCURRENCY. Tests. The two `get_info_every_named_info_type_has_the_declared_shape_*` tests asserted SQL_SUCCESS where core now documents that the shape probe's zero-length buffer is total truncation; they assert "not SQL_ERROR", which is what their own messages always claimed. `dbms_ver_is_well_formed` and `driver_ver_is_well_formed` read through the hooks, the first via `test_connection` since it needs a data source. `SQL_MULTIPLE_ACTIVE_TXN` has no `odbc_sys::InfoType` variant, so it is pinned through the raw path — the snapshot iterates named types only. Also fixes a pre-existing rustdoc failure that only surfaced once the crate compiled again: a public doc comment linked to the `pub(crate)` `SqliteConnection::interrupt`. Verified against stackable-odbc-core dd25a22: cargo test 280 passing, clippy clean, `pre-commit run --all-files` green across all 15 hooks, and the pyodbc suite 23/23 through real unixODBC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 101 ++++++-- CHANGELOG.md | 45 ++++ src/backend.rs | 261 +++++++++++++++----- src/backend/execute.rs | 186 +++++++++++++- src/backend/info.rs | 240 ++++++++++++------ src/backend/metadata.rs | 465 +++++++++++++++++------------------ src/ffi_integration_tests.rs | 53 +++- 7 files changed, 962 insertions(+), 389 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e1c33b..cb33f81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ the 73 C ABI entry points — lives in | [Declaring capabilities](#declaring-capabilities) | Adding or changing any `SQLGetInfo` value | | [Transactions](#transactions) | Touching `SQLEndTran`, autocommit or cursor behaviour | | [Cancellation](#cancellation) | Touching `SQLCancel` or `SQL_ATTR_QUERY_TIMEOUT` | +| [`row_count` has three answers](#row_count-has-three-answers-not-two) | Touching `SQLRowCount` or the execute path | | [Catalog functions](#catalog-functions) | Touching anything in `metadata.rs` | | [Architecture](#architecture-of-this-crate) | Understanding the module layout | | [Connection string keys](#connection-string-keys) | Adding or changing a parameter | @@ -179,16 +180,21 @@ database file is `08001`. Failures after that point are `08S01`. ### Declaring capabilities -`Backend` has around two dozen **required** methods that state what SQLite can +`Backend` has around thirty **required** methods that state what SQLite can do — `alter_table_support`, `outer_join_capabilities`, `subqueries`, `sql_conformance`, `supports_catalogs`, `identifier_case`, -`txn_isolation_options`, `table_types` and the rest. They are required, with no -default, deliberately: a defaulted capability is a claim no backend ever made, -and every one of them was a bug here before core made it a compile error. -`table_types` is required for the same reason and one of its own: an empty -table-type list is an *answer* ("this data source has no table types"), not -"unknown", and unlike catalogs and schemas there is no `supports_*` method for -core to derive it from. +`quoted_identifier_case`, `txn_capable`, `txn_isolation_options`, `integrity`, +`multiple_active_txn`, `special_characters`, `accessible_procedures`, +`dbms_name`, `dbms_version`, `table_types` and the rest. They are required, +with no default, deliberately: a defaulted capability is a claim no backend +ever made, and every one of them was a bug here before core made it a compile +error. `table_types` is required for the same reason and one of its own: an +empty table-type list is an *answer* ("this data source has no table types"), +not "unknown", and unlike catalogs and schemas there is no `supports_*` method +for core to derive it from. `special_characters` is required on that same +principle — `""` asserts that nothing beyond the alphanumerics and underscore +is legal unquoted, which is a claim, not an absence, and inheriting it as a +default is how this driver came to under-report `$`. They all take `&Self::Connection`, because `SQLGetInfo` is a per-connection call and a data source's capabilities can differ by server. Every one this @@ -196,9 +202,13 @@ driver declares is a property of the SQLite `rusqlite` links, not of the file opened, so each ignores the argument — but the answer must still be read through a connection, and the tests do that via `info::tests::test_connection` rather than calling the hook as a free function. `cursor_commit_behavior`, -`cursor_rollback_behavior` and `catalog_result_column_widths` are the -exceptions and take none: `SQLGetInfo` must answer the first two before a -connection exists. +`cursor_rollback_behavior`, `catalog_result_column_widths`, `driver_name` and +`driver_version` are the exceptions and take none: `SQLGetInfo` must answer the +first three before a connection exists, and the Windows Driver Manager asks for +driver identity before `SQLDriverConnectW`. Note the split within the identity +group — `driver_name`/`driver_version` describe the driver and take no +connection, while `dbms_name`/`dbms_version` describe what was connected to and +take one. The same split runs through `get_info`. `sqlite_get_info` takes `Option<&SqliteConnection>` — `None` on the pre-connect path — and hands it to @@ -366,14 +376,77 @@ This is load-bearing well beyond memory use. It is why the cursor-behaviour hooks report `Preserve`, why `SQLEndTran` cannot disturb a cursor, and why concurrency is a non-issue. Changing it is not a local optimisation. +### `row_count` has three answers, not two + +`StatementBackend::row_count` returns `Option<i64>`, and core reads all three +possibilities differently: + +| Answer | Means | Here | +|--------|-------|------| +| `Some(n)` | the backend counted | a searched INSERT / UPDATE / DELETE, or a materialised result set | +| `Some(-1)` | `SQL_NO_TOTAL`, cannot determine | a count exceeding `i64`; unreachable in practice | +| `None` | not applicable to this statement | DDL, transaction control, `PRAGMA`, an unexecuted prepared statement | + +The distinction between the last two is not cosmetic. Core turns a statement +with **zero columns** reporting **`Some(0)`** into `SQL_NO_DATA`, which is +`SQLExecDirect`'s documented behaviour for "a searched update, insert, or +delete statement that doesn't affect any rows". Answering `Some(0)` for DDL +therefore made every `CREATE TABLE` return `SQL_NO_DATA`. + +SQLite offers no predicate for "is this DML" — `sqlite3_stmt_readonly` is false +for DDL too — so `execute::is_searched_dml` decides it from the statement's +leading keyword, past whitespace and both comment forms. `REPLACE` and `WITH` +count alongside the obvious three: the first is an `INSERT OR REPLACE` alias, +and the second fronts a CTE, which is only ever consulted for a zero-column +statement, so a `WITH` that declared no columns cannot be a `WITH ... SELECT`. +Being wrong is not symmetric, so an unrecognised keyword answers "no count": +withholding a count leaves `SQLRowCount` at -1, while inventing one fabricates +`SQL_NO_DATA`. + +Do **not** replace this with the number `rusqlite`'s `execute()` returns. +`sqlite3_changes()` reports the rows touched by the *most recently completed* +INSERT, UPDATE or DELETE, so a `CREATE TABLE` run after a three-row `INSERT` is +handed that `3`. `ddl_after_dml_does_not_inherit_the_dml_row_count` pins it. + ### Catalog functions -The six catalog methods return **typed row vectors** — `Vec<TableRow>`, +The six catalog methods take a **typed query object** — `&TablesQuery`, +`&ColumnsQuery`, `&PrimaryKeysQuery`, `&ForeignKeysQuery`, `&StatisticsQuery`, +`&SpecialColumnsQuery` — and return **typed row vectors** — `Vec<TableRow>`, `Vec<ColumnRow>`, `Vec<PrimaryKeyRow>`, `Vec<ForeignKeyRow>`, `Vec<StatisticsRow>`, `Vec<SpecialColumnRow>` — not a `Self::Statement`. Core converts each row to the spec's column layout, sorts the set into the order -that function's spec page mandates, and serves it. Three consequences for -anything changed in `metadata.rs`: +that function's spec page mandates, and serves it. + +Both sides are core's types and both are sealed, which is what a change in +`metadata.rs` has to work with: + +- **Neither has a struct expression here.** Every row type is + `#[non_exhaustive]`, so a row is built from `Default` and the consuming + setter per column: `TableRow::default().name(n).table_type(t)`. Each setter + takes `impl Into<T>`, so an `Option<String>` column accepts a bare `String`. + A column a driver does not populate is simply not named — which is the point, + since it makes a column added to a spec result set a core-only change instead + of a break in every driver. The query types are sealed the same way, with + crate-private fields, an accessor and a `with_*` setter per field, and a + `new()` for the arguments that have no honest default (`StatisticsQuery`'s + `unique_only`, `SpecialColumnsQuery`'s `identifier_type`/`scope`/`nullable`). +- **Read the filters off the query, do not destructure it.** The run of + same-typed `Option<&str>` arguments these hooks used to take is exactly what + the query types exist to remove: `SQLForeignKeys` took six in a row, where + swapping a primary-key argument for its foreign-key counterpart compiled + without complaint. Unpacking a query back into positional arguments at the + trait boundary reintroduces that hazard one layer down, so the query travels + all the way into `metadata.rs`. +- **`TablesQuery::table_types()` is already parsed.** Core splits `TableType` + on commas and strips the optional single quotes — it is a value list, not a + pattern, and `SQL_ATTR_METADATA_ID` never applies to it — so a backend gets a + `&[String]` and never parses it. Empty means no filter. A lone `"%"` does + still arrive, because the `SQL_ALL_TABLE_TYPES` enumeration core answers + itself additionally requires the other three arguments to be empty strings; + `metadata::tables` reads that as no filter. + +Three further consequences for anything changed in `metadata.rs`: - **Do not sort, and do not add an `ORDER BY` for ODBC's sake.** Core sorts, stably, on the spec's keys. A second ordering in the backend is one more diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e77df..3f3f035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `SQL_QUOTED_IDENTIFIER_CASE` reports `SQL_IC_MIXED` instead of + `SQL_IC_SENSITIVE`. In SQLite, double quotes are a *delimiter* — they let a + keyword or a name with punctuation be used as an identifier — and do not + switch on case-sensitive matching the way they do in a SQL-92 conformant + DBMS: a table created as `"MixedCase"` is found by `"mixedcase"`, and the + catalog stores the name with the case it was written in. The old value told + an application that `"T"` and `"t"` were different tables. Both halves of the + new claim — case-insensitive matching and mixed-case storage — are probed + against the bundled library rather than read off the documentation. + +- `SQL_SPECIAL_CHARACTERS` reports `$` instead of the empty string. SQLite's + tokenizer treats `$` as an identifier character, so `a$b` parses undelimited + and round-trips through `sqlite_master` unchanged. An application reads this + info type to decide when it must quote, and the empty string had it quoting a + name that needs no quoting. The empty string was `stackable-odbc-core`'s + default rather than a claim this driver ever made; it is now a per-connection + `Backend` hook, and every candidate character is executed against the bundled + library, the rejected ones included. + +- `SQL_CURSOR_SENSITIVITY` reports `SQL_UNSPECIFIED` instead of + `SQL_INSENSITIVE`, and `SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES2` reports + `SQL_CA2_READ_ONLY_CONCURRENCY` instead of `0`. Both describe + `stackable-odbc-core`'s own fetch path rather than SQLite, and both now come + from core: insensitivity would be a promise that no other cursor's changes + become visible, which core does not make about rows it has not read yet, + while `0` for the second denied the one concurrency + `SQLSetStmtAttr(SQL_ATTR_CONCURRENCY)` actually accepts. This follows a + `stackable-odbc-core` change. + - `SQLDescribeCol` and `SQLColAttribute` report each result column's real nullability instead of claiming every column is nullable. A column declared `NOT NULL` is now `SQL_NO_NULLS`, a plain table column `SQL_NULLABLE`, and a @@ -224,6 +253,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `SQLRowCount` reported `0` after a `CREATE TABLE`, `DROP TABLE`, `ALTER + TABLE`, `BEGIN`, `COMMIT`, `PRAGMA` or `VACUUM`, where the spec's + affected-row count does not apply at all. The three answers are now distinct: + a count for a searched INSERT / UPDATE / DELETE, the materialised size of a + result set, and *no count* for everything else. This matters beyond + tidiness — `stackable-odbc-core` reads a zero-column statement reporting a + counted zero as `SQL_NO_DATA`, per `SQLExecDirect`'s Comments, so every DDL + statement this driver ran returned `SQL_NO_DATA` to the application instead + of `SQL_SUCCESS`. A searched DELETE that matches nothing still reports `0`, + which is the case the spec reserves `SQL_NO_DATA` for. + + The same fix removes a stale count: `sqlite3_changes()` reports the rows + touched by the *most recently completed* INSERT, UPDATE or DELETE, so a + `CREATE TABLE` run straight after a three-row `INSERT` was handed that `3` + and reported it. + - `SQLForeignKeys` reported `PKCOLUMN_NAME` as NULL for a foreign key declared without an explicit column list (`REFERENCES parent`), a column the spec marks "not NULL". SQLite defines the implicit target as the parent table's diff --git a/src/backend.rs b/src/backend.rs index 1e4f60c..cadea02 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -10,8 +10,8 @@ use stackable_odbc_core::{ types::{ ColumnDescriptor, ColumnRow, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, ForeignKeyRow, InfoValue, PrimaryKeyRow, SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, - SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TXN_SERIALIZABLE, SpecialColumnRow, - StatisticsRow, TableRow, TypeInfoRow, + SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TC_DML, SQL_TXN_SERIALIZABLE, + SpecialColumnRow, StatisticsRow, TableRow, TypeInfoRow, }, }; @@ -60,8 +60,14 @@ pub struct SqliteStatement { pub(crate) prepared_sql: Option<String>, columns: Vec<ColumnDescriptor>, rows: Vec<Vec<ColumnValue>>, - cursor: i64, // -1 = before first row - affected_rows: Option<usize>, // Some(n) for DML; None for SELECT (use rows.len()) + cursor: i64, // -1 = before first row + /// `Some(n)` for a searched INSERT / UPDATE / DELETE, which is the only + /// case with an affected-row count. `None` everywhere else: a SELECT + /// reports its materialised row count instead, and DDL, transaction + /// control and the rest have no count at all. See + /// `StatementBackend::row_count` in `execute.rs` for why the distinction + /// between "counted zero" and "no count" is load-bearing. + affected_rows: Option<usize>, } impl SqliteStatement { @@ -76,16 +82,19 @@ impl SqliteStatement { } } - /// Create a new SqliteStatement representing a completed DML statement - /// (INSERT / UPDATE / DELETE / DDL). `affected_rows` is the count reported - /// by rusqlite's `execute()`. - pub fn dml(affected_rows: usize) -> Self { + /// Create a new SqliteStatement representing a completed statement that + /// produced no result set — DML, DDL, transaction control or a PRAGMA. + /// + /// `affected_rows` is `Some` only for a searched INSERT / UPDATE / DELETE, + /// carrying the count reported by rusqlite's `execute()`; everything else + /// passes `None`. See `execute::is_searched_dml`. + pub fn non_query(affected_rows: Option<usize>) -> Self { Self { prepared_sql: None, columns: vec![], rows: vec![], cursor: -1, - affected_rows: Some(affected_rows), + affected_rows, } } @@ -398,7 +407,9 @@ impl Backend for SqliteBackend { /// Hand out the connection's interrupt handle. Infallible and lock-free: /// the handle was captured in [`SqliteBackend::connect`], so this only - /// bumps a refcount — see [`SqliteConnection::interrupt`]. + /// bumps a refcount — see `SqliteConnection::interrupt`. Not an intra-doc + /// link: that field is `pub(crate)`, and rustdoc rejects a public item + /// linking to a private one. fn cancel_token(conn: &SqliteConnection) -> Arc<rusqlite::InterruptHandle> { Arc::clone(&conn.interrupt) } @@ -556,14 +567,36 @@ impl Backend for SqliteBackend { /// carries ICU; that does not change the answer, since ODBC has no value /// for "case-insensitive for some characters". /// - /// Distinct from `SQL_QUOTED_IDENTIFIER_CASE`, which core answers, and - /// which is `SQL_IC_SENSITIVE` here: a quoted `"T"` does not match `"t"`. + /// Distinct from [`SqliteBackend::quoted_identifier_case`], which describes + /// *quoted* identifiers — and which answers the same here, for the reason + /// given there. /// /// <https://sqlite.org/lang_keywords.html> fn identifier_case(_conn: &SqliteConnection) -> u16 { SQL_IC_MIXED } + /// `SQL_IC_MIXED`, the same as [`SqliteBackend::identifier_case`]: in + /// SQLite quoting an identifier does **not** make it case-sensitive. + /// + /// This is the one place the two commonly diverge for other data sources, + /// so it is worth stating what SQLite actually does. Double quotes are a + /// *delimiter* here, not a case-sensitivity switch: they let a keyword or a + /// name with punctuation be used as an identifier, and nothing more. A + /// table created as `"MixedCase"` is still found by `"mixedcase"`, and the + /// catalog stores the name with the case it was written in — which is + /// precisely `SQL_IC_MIXED`. + /// + /// `quoted_identifiers_are_not_case_sensitive` probes this against the + /// bundled library rather than taking it from the documentation. It + /// corrects a claim of `SQL_IC_SENSITIVE`, which would have had an + /// application quote-and-case-match identifiers that SQLite folds anyway. + /// + /// <https://sqlite.org/lang_keywords.html> + fn quoted_identifier_case(_conn: &SqliteConnection) -> u16 { + SQL_IC_MIXED + } + /// SQLite has no ODBC catalogs: `metadata::tables` reports `TABLE_CAT` as /// NULL for every row, and a `catalog = "%"` enumeration returns an empty /// result set. @@ -628,6 +661,65 @@ impl Backend for SqliteBackend { SQL_TXN_SERIALIZABLE } + /// `SQL_TC_DML`: SQLite runs DML inside a transaction, and a DDL statement + /// inside one causes neither a commit nor an error — SQLite's DDL is + /// transactional, so `CREATE TABLE` simply participates. + /// + /// `SQL_TC_ALL` would be the stronger claim and is tempting for that + /// reason, but the spec defines it as "transactions can contain DDL + /// statements **and** DML statements in any order", and this driver's + /// manual-commit mode is built on `BEGIN`/`COMMIT` around whatever the + /// application sends. `SQL_TC_DML` states what an application can rely on + /// without also promising the DDL-ordering freedom the spec attaches to + /// `SQL_TC_ALL`. + /// + /// Core pins this against [`SqliteBackend::txn_isolation_options`]: + /// `SQL_TC_NONE` if and only if no isolation level is declared. Declaring a + /// level and then reporting no transaction support is the + /// self-contradiction that pairing exists to catch. + /// + /// `SQL_TC_DML` is a small fixed constant, so the narrowing `as u16` — the + /// `SQL_TC_*` constants are typed `u32` for bitmask use, while the info + /// type is `SQLUSMALLINT` — cannot lose information. + fn txn_capable(_conn: &SqliteConnection) -> u16 { + SQL_TC_DML as u16 + } + + /// `true`: each connection this driver opens is its own + /// `rusqlite::Connection` with its own SQLite handle, so two connections + /// can each have a transaction open at the same time. + /// + /// The spec asks about the *driver*, not about one connection: "`"Y"` if + /// the driver supports more than one active transaction at the same time". + /// Nothing here serialises across connections — `SqliteBackend::connect` + /// opens a fresh handle per call and shares no state between them. What + /// SQLite does when those transactions contend for the same file is a + /// locking question (`SQLITE_BUSY`), not a question of how many can be + /// active. + fn multiple_active_txn(_conn: &SqliteConnection) -> bool { + true + } + + /// `true`: SQLite implements the whole Integrity Enhancement Facility — + /// `PRIMARY KEY`, `UNIQUE`, `NOT NULL`, `CHECK`, `DEFAULT` and `FOREIGN + /// KEY` with referential actions — and this build enforces all of it. + /// + /// Referential integrity in particular is enforced by construction, not by + /// chance: [`SqliteBackend::connect`] issues `PRAGMA foreign_keys = ON`, + /// because plain SQLite defaults it off for backward compatibility and the + /// bundled library only *happens* to compile with + /// `SQLITE_DEFAULT_FOREIGN_KEYS`. Without the pragma this claim would + /// depend on a dependency's build flags. + /// `integrity_enhancement_facility_is_actually_enforced` asserts it through + /// `connect`, and fails loudly if that ever stops holding. + /// + /// `SQLForeignKeys` is genuinely implemented (`metadata::foreign_keys`, + /// over `PRAGMA foreign_key_list`), so an application that acts on this + /// finds the metadata it then asks for. + fn integrity(_conn: &SqliteConnection) -> bool { + true + } + /// `SQL_GB_NO_RELATION`: SQLite relates the `GROUP BY` list and the select /// list not at all. It accepts a bare non-aggregated column absent from /// `GROUP BY` (returning an arbitrary row from each group), and accepts @@ -739,6 +831,19 @@ impl Backend for SqliteBackend { true } + /// `false`: SQLite has no stored procedures, so there is no procedure the + /// connected user can execute. + /// + /// The counterpart of [`SqliteBackend::accessible_tables`], and the + /// opposite answer for a different reason. That one is `true` because every + /// table SQLTables returns is reachable; this is `false` because + /// `SQLProcedures` returns nothing to be reachable in the first place — + /// this driver leaves `Backend::procedures` defaulted to no rows, and + /// reports `SQL_PROCEDURES = "N"` through core. + fn accessible_procedures(_conn: &SqliteConnection) -> bool { + false + } + /// `false`: the driver opens the database read-write. /// /// This describes the driver's own behaviour, not the file. A database on @@ -759,6 +864,31 @@ impl Backend for SqliteBackend { Cow::Borrowed(info::sqlite_keywords()) } + /// `"$"` — the one character beyond `a`–`z`, `A`–`Z`, `0`–`9` and `_` that + /// SQLite accepts in an undelimited identifier. + /// + /// SQLite's tokenizer treats `$` as an identifier character, so + /// `CREATE TABLE a$b (...)` parses and the name round-trips through + /// `sqlite_master` unchanged. An application reads this info type to decide + /// when it must quote, so the previous `""` — core's old default, not a + /// claim this driver ever made — told it to quote a name that needs no + /// quoting. + /// + /// Every candidate is executed against the bundled library in + /// `special_characters_are_each_live_probed`, which checks the characters + /// *not* claimed as well: a list that only grows when someone notices can + /// understate forever. + /// + /// Deliberately excluded even though SQLite's tokenizer accepts them: + /// characters at or above `0x80`. The spec wants a character list, and + /// "every non-ASCII code point" is not one that fits in a `SQLGetInfo` + /// string. + /// + /// <https://sqlite.org/lang_keywords.html> + fn special_characters(_conn: &SqliteConnection) -> Cow<'static, str> { + Cow::Borrowed(info::SQLITE_SPECIAL_CHARACTERS) + } + /// Backslash: SQLite's `LIKE ... ESCAPE` takes any character, and this /// driver reports `SQL_LIKE_ESCAPE_CLAUSE = "Y"`. Backslash is the /// conventional choice and the one `SQLTables`-style pattern arguments are @@ -767,6 +897,58 @@ impl Backend for SqliteBackend { Cow::Borrowed("\\") } + // --- Identity --- + // + // `driver_name` and `driver_version` take no connection: the Windows + // Driver Manager asks for driver identity before `SQLDriverConnectW`, and + // an answer that needed a connection could not be given then. The two DBMS + // values are per-connection by signature but constant here, since a + // `rusqlite` link always reaches the one bundled library. + + fn driver_name() -> Cow<'static, str> { + Cow::Borrowed("stackable-odbc-sqlite") + } + + /// This crate's version in the spec's `##.##.####` form. + /// + /// `driver_version!` reads it from `CARGO_PKG_VERSION` at compile time, so + /// a release bump cannot leave the reported version behind; + /// `driver_version_tracks_the_crate_version` pins that. + fn driver_version() -> Cow<'static, str> { + stackable_odbc_core::driver_version!().into() + } + + fn dbms_name(_conn: &SqliteConnection) -> Cow<'static, str> { + Cow::Borrowed("SQLite") + } + + /// The bundled library's version, `##.##.####` followed by SQLite's own + /// spelling in parentheses. + /// + /// The spec permits appending the data source's own version string after + /// the fixed-width prefix, which keeps the familiar `3.53.2` visible to + /// anyone reading the value by eye. Read from `rusqlite::version()` — the + /// library actually linked — rather than written down, for the reason + /// AGENTS.md gives about the system `sqlite3` binary being a different + /// version. + fn dbms_version(_conn: &SqliteConnection) -> Cow<'static, str> { + use stackable_odbc_core::types::{format_odbc_version, parse_dotted_version}; + + let raw = rusqlite::version(); + match parse_dotted_version(raw) { + Some((major, minor, release)) => { + format!("{} ({raw})", format_odbc_version(major, minor, release)).into() + } + None => { + tracing::warn!( + raw, + "could not parse the SQLite version; reporting it verbatim" + ); + raw.into() + } + } + } + // --- Delegations --- fn exec_direct( @@ -825,12 +1007,9 @@ impl Backend for SqliteBackend { fn tables( conn: &SqliteConnection, _cancel: &Arc<rusqlite::InterruptHandle>, - catalog: Option<&str>, - schema: Option<&str>, - table: Option<&str>, - table_type: Option<&str>, + query: &stackable_odbc_core::types::TablesQuery<'_>, ) -> Result<Vec<TableRow>, SqliteError> { - metadata::tables(conn, catalog, schema, table, table_type) + metadata::tables(conn, query) } /// `TABLE` and `VIEW` — the two values `metadata::tables` can put in @@ -842,69 +1021,41 @@ impl Backend for SqliteBackend { fn columns( conn: &SqliteConnection, _cancel: &Arc<rusqlite::InterruptHandle>, - catalog: Option<&str>, - schema: Option<&str>, - table: Option<&str>, - column: Option<&str>, + query: &stackable_odbc_core::types::ColumnsQuery<'_>, ) -> Result<Vec<ColumnRow>, SqliteError> { - metadata::columns(conn, catalog, schema, table, column) + metadata::columns(conn, query) } fn primary_keys( conn: &SqliteConnection, _cancel: &Arc<rusqlite::InterruptHandle>, - catalog: Option<&str>, - schema: Option<&str>, - table: Option<&str>, + query: &stackable_odbc_core::types::PrimaryKeysQuery<'_>, ) -> Result<Vec<PrimaryKeyRow>, SqliteError> { - metadata::primary_keys(conn, catalog, schema, table) + metadata::primary_keys(conn, query) } fn foreign_keys( conn: &SqliteConnection, _cancel: &Arc<rusqlite::InterruptHandle>, - pk_catalog: Option<&str>, - pk_schema: Option<&str>, - pk_table: Option<&str>, - fk_catalog: Option<&str>, - fk_schema: Option<&str>, - fk_table: Option<&str>, + query: &stackable_odbc_core::types::ForeignKeysQuery<'_>, ) -> Result<Vec<ForeignKeyRow>, SqliteError> { - metadata::foreign_keys( - conn, pk_catalog, pk_schema, pk_table, fk_catalog, fk_schema, fk_table, - ) + metadata::foreign_keys(conn, query) } fn statistics( conn: &SqliteConnection, _cancel: &Arc<rusqlite::InterruptHandle>, - catalog: Option<&str>, - schema: Option<&str>, - table: Option<&str>, - unique_only: bool, + query: &stackable_odbc_core::types::StatisticsQuery<'_>, ) -> Result<Vec<StatisticsRow>, SqliteError> { - metadata::statistics(conn, catalog, schema, table, unique_only) + metadata::statistics(conn, query) } fn special_columns( conn: &SqliteConnection, _cancel: &Arc<rusqlite::InterruptHandle>, - identifier_type: stackable_odbc_core::types::IdentifierType, - catalog: Option<&str>, - schema: Option<&str>, - table: Option<&str>, - scope: stackable_odbc_core::types::Scope, - nullable: stackable_odbc_core::types::Nullable, + query: &stackable_odbc_core::types::SpecialColumnsQuery<'_>, ) -> Result<Vec<SpecialColumnRow>, SqliteError> { - metadata::special_columns( - conn, - identifier_type, - catalog, - schema, - table, - scope, - nullable, - ) + metadata::special_columns(conn, query) } /// SQLite's `{fn}`/`{d}`/`{t}`/`{ts}` escape-translation dialect. See diff --git a/src/backend/execute.rs b/src/backend/execute.rs index 4fc06fa..7a01514 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -84,6 +84,73 @@ fn describe_column( } } +/// The first bare word of `sql`, with leading whitespace and SQL comments +/// skipped. The empty string when there is none. +/// +/// SQLite accepts both comment forms before the opening keyword, and an +/// unterminated block comment is legal — it swallows the rest of the text — so +/// both are handled rather than assumed away. +fn leading_keyword(sql: &str) -> &str { + let mut rest = sql.trim_start(); + loop { + if let Some(after) = rest.strip_prefix("--") { + rest = match after.find('\n') { + Some(newline) => after[newline + 1..].trim_start(), + None => return "", + }; + } else if let Some(after) = rest.strip_prefix("/*") { + rest = match after.find("*/") { + Some(end) => after[end + 2..].trim_start(), + None => return "", + }; + } else { + break; + } + } + let end = rest + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(rest.len()); + &rest[..end] +} + +/// Whether `sql` is a searched INSERT, UPDATE or DELETE — the only statements +/// that have an affected-row count for `SQLRowCount` to report. +/// +/// Core reads [`StatementBackend::row_count`] as three distinct answers: +/// `Some(n)` is "the backend counted", `Some(SQL_NO_TOTAL)` is "cannot +/// determine", and `None` is "not applicable to this statement". It turns a +/// zero-column statement answering `Some(0)` into `SQL_NO_DATA`, per +/// `SQLExecDirect`'s Comments — "if SQLExecDirect executes a searched update, +/// insert, or delete statement that doesn't affect any rows at the data +/// source, the call to SQLExecDirect returns SQL_NO_DATA". A `CREATE TABLE` +/// answering `Some(0)` therefore looked to an application exactly like a +/// searched DELETE that matched nothing. +/// +/// SQLite exposes no predicate for this — `sqlite3_stmt_readonly` is false for +/// DDL too, and `sqlite3_changes()` is worse than useless here, since it holds +/// the count from the *most recently completed* INSERT, UPDATE or DELETE and +/// so reports a stale count after a `CREATE TABLE`. The leading keyword is what +/// is left. Two SQLite specifics beyond the obvious three: +/// +/// - `REPLACE` is an alias for `INSERT OR REPLACE` and counts the same way. +/// - `WITH` fronts a CTE, which SQLite permits before an INSERT, UPDATE or +/// DELETE as well as before a SELECT. This is only ever consulted for a +/// statement that declared no result columns, and a `WITH ... SELECT` +/// declares its columns, so a zero-column `WITH` is necessarily one of the +/// three. +/// +/// The unrecognised case answers `false` because being wrong is not symmetric: +/// withholding a count that exists leaves `SQLRowCount` reporting -1, while +/// inventing one where there is none fabricates `SQL_NO_DATA`. +fn is_searched_dml(sql: &str) -> bool { + const DML_KEYWORDS: [&str; 5] = ["INSERT", "REPLACE", "UPDATE", "DELETE", "WITH"]; + + let keyword = leading_keyword(sql); + DML_KEYWORDS + .iter() + .any(|dml| keyword.eq_ignore_ascii_case(dml)) +} + pub(super) fn exec_direct( conn: &SqliteConnection, sql: &str, @@ -96,10 +163,12 @@ pub(super) fn exec_direct( let mut stmt = db.prepare(sql).map_err(map_sqlite_error)?; // Statements with no result columns are DML (INSERT/UPDATE/DELETE) or DDL. - // Use execute() to run them and capture the affected-row count. + // Use execute() to run them; only the DML has an affected-row count. if stmt.column_count() == 0 { let n = db.execute(sql, []).map_err(map_sqlite_error)?; - return Ok(SqliteStatement::dml(n)); + return Ok(SqliteStatement::non_query( + is_searched_dml(sql).then_some(n), + )); } // SELECT path: collect column metadata, then eagerly fetch all rows. @@ -181,7 +250,7 @@ pub(super) fn execute( .map_err(map_sqlite_error)?; stmt.columns = vec![]; stmt.rows = vec![]; - stmt.affected_rows = Some(n); + stmt.affected_rows = is_searched_dml(&sql).then_some(n); stmt.cursor = -1; // SQLite has no stored-procedure output parameters. return Ok(ExecuteOutcome::default()); @@ -288,14 +357,29 @@ impl StatementBackend for SqliteStatement { /// `i64` because `SQLRowCount` writes through a signed `SQLLEN *`. /// - /// A count that does not fit reports `SQL_NO_TOTAL` (-1), the spec's "the - /// driver cannot determine the row count" — which is what a value this - /// type cannot name actually means. It is unreachable in practice: rows - /// are materialised in memory, so `i64::MAX` of them cannot be held. + /// Three answers, and core distinguishes all three — see + /// [`is_searched_dml`] for what it does with them: + /// + /// - **`Some(n)`** for a searched INSERT / UPDATE / DELETE, and for a + /// result set, whose materialised size this driver genuinely knows. + /// - **`Some(SQL_NO_TOTAL)`** for a count that does not fit `i64`, the + /// spec's "the driver cannot determine the row count" — which is what a + /// value this type cannot name actually means. Unreachable in practice: + /// rows are materialised in memory, so `i64::MAX` of them cannot be held. + /// - **`None`** for a statement with no affected-row count at all: DDL, + /// transaction control, a PRAGMA, or a prepared statement not yet + /// executed. Answering `Some(0)` for these is what made a successful + /// `CREATE TABLE` report `SQL_NO_DATA`. fn row_count(&self) -> Option<i64> { const SQL_NO_TOTAL: i64 = -1; - let count = self.affected_rows.unwrap_or(self.rows.len()); + let count = match self.affected_rows { + Some(affected) => affected, + // No affected-row count. A result set still has a size worth + // reporting; a statement that produced neither has nothing. + None if !self.columns.is_empty() => self.rows.len(), + None => return None, + }; Some(i64::try_from(count).unwrap_or(SQL_NO_TOTAL)) } @@ -372,6 +456,92 @@ mod tests { assert_eq!(stmt.column_count(), 0); } + /// A zero-column statement that reports `Some(0)` is what core turns into + /// `SQL_NO_DATA`, so DDL must report `None` — "no affected-row count" — + /// rather than "counted zero". With `Some(0)` here every `CREATE TABLE` + /// this driver ran came back as `SQL_NO_DATA`, and the whole FFI test + /// suite's table setup failed. + #[test] + fn exec_direct_ddl_reports_no_row_count_rather_than_zero() { + let conn = conn_with("CREATE TABLE t (id INTEGER);"); + + for sql in [ + "CREATE TABLE more (x TEXT)", + "CREATE INDEX ix_t_id ON t(id)", + "DROP INDEX ix_t_id", + "ALTER TABLE t ADD COLUMN extra TEXT", + ] { + let stmt = exec_direct(&conn, sql).unwrap(); + assert_eq!( + stmt.row_count(), + None, + "{sql} has no affected-row count; Some(0) here is SQL_NO_DATA" + ); + } + } + + /// The stale-count hazard, which is why the leading keyword decides this + /// rather than the number SQLite hands back. `sqlite3_changes()` reports + /// the count from the *most recently completed* INSERT, UPDATE or DELETE, + /// so a `CREATE TABLE` run straight after a three-row INSERT is handed a + /// `3` that has nothing to do with it. + #[test] + fn ddl_after_dml_does_not_inherit_the_dml_row_count() { + let conn = conn_with("CREATE TABLE t (id INTEGER);"); + let insert = exec_direct(&conn, "INSERT INTO t VALUES (1), (2), (3)").unwrap(); + assert_eq!(insert.row_count(), Some(3)); + + let ddl = exec_direct(&conn, "CREATE TABLE later (x TEXT)").unwrap(); + assert_eq!( + ddl.row_count(), + None, + "the CREATE TABLE must not inherit the INSERT's count" + ); + } + + /// A searched DELETE matching nothing is the case the spec actually + /// reserves `SQL_NO_DATA` for, and it must keep reporting `Some(0)` — the + /// fix above must not suppress it along with the DDL. + #[test] + fn searched_dml_matching_nothing_still_counts_zero() { + let conn = conn_with("CREATE TABLE t (id INTEGER);"); + let stmt = exec_direct(&conn, "DELETE FROM t WHERE id = 99").unwrap(); + assert_eq!(stmt.row_count(), Some(0)); + } + + /// `is_searched_dml` reads the leading keyword, so it must see past + /// leading whitespace and both of SQLite's comment forms, and must accept + /// the two spellings beyond the obvious three. + #[test] + fn searched_dml_is_recognised_through_comments_and_aliases() { + for sql in [ + "INSERT INTO t VALUES (1)", + " \n\t update t SET a = 1", + "delete from t", + "REPLACE INTO t VALUES (1)", + "-- a leading line comment\nINSERT INTO t VALUES (1)", + "/* a leading block comment */ DELETE FROM t", + "WITH c AS (SELECT 1) INSERT INTO t SELECT * FROM c", + ] { + assert!(is_searched_dml(sql), "{sql:?} is searched DML"); + } + + for sql in [ + "CREATE TABLE t (a INTEGER)", + "DROP TABLE t", + "ALTER TABLE t RENAME TO u", + "BEGIN", + "COMMIT", + "PRAGMA foreign_keys = ON", + "VACUUM", + "-- an unterminated line comment", + "/* an unterminated block comment", + "", + ] { + assert!(!is_searched_dml(sql), "{sql:?} has no affected-row count"); + } + } + #[test] fn exec_direct_syntax_error_maps_to_42000() { let conn = conn_with("CREATE TABLE t (id INTEGER);"); diff --git a/src/backend/info.rs b/src/backend/info.rs index 94d15b1..ea1d09a 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -25,9 +25,8 @@ use stackable_odbc_core::types::{ SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_NATURAL_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, SQL_STRING_FUNCTIONS, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, - SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TC_DML, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, + SQL_SVE_NULLIF, SQL_SYSTEM_FUNCTIONS, SQL_TIMEDATE_FUNCTIONS, SQL_TXN_SERIALIZABLE, SQL_U_UNION, SQL_U_UNION_ALL, SqlDataType, TypeInfoRow, catalog_column_size, - format_odbc_version, parse_dotted_version, }; use super::SqliteBackend; @@ -365,31 +364,15 @@ fn sqlite_get_info( conn: Option<&SqliteConnection>, info_type: InfoType, ) -> Result<InfoValue, SqliteError> { - // Driver-specific overrides + // Driver-specific overrides. + // + // The identity group (`SQL_DRIVER_NAME`, `SQL_DRIVER_VER`, + // `SQL_DBMS_NAME`, `SQL_DBMS_VER`) is deliberately absent: each is a + // `Backend` hook now, so core answers all four, and stating them here as + // well would be the "declare it once" violation AGENTS.md describes. + // `SQL_INTEGRITY` and `SQL_TXN_CAPABLE` moved for the same reason. + // `get_info_snapshot` still pins every value an application sees. match info_type { - InfoType::DriverName => return Ok(InfoValue::String("stackable-odbc-sqlite".into())), - InfoType::DriverVer => { - return Ok(InfoValue::String(stackable_odbc_core::driver_version!())); - } - InfoType::DbmsName => return Ok(InfoValue::String("SQLite".into())), - InfoType::DbmsVer => { - let raw = rusqlite::version(); - // The spec permits appending the data source's own version string - // after the ##.##.#### prefix, which keeps SQLite's native - // spelling visible to anyone reading the value by eye. - return Ok(InfoValue::String(match parse_dotted_version(raw) { - Some((major, minor, release)) => { - format!("{} ({raw})", format_odbc_version(major, minor, release)) - } - None => { - tracing::warn!( - raw, - "could not parse the SQLite version; reporting it verbatim" - ); - raw.to_string() - } - })); - } // 0, not an identifier length: this driver reports no catalogs and no // schemas, so there is no name whose maximum length these could // describe. Core defaults them to its generic identifier length, which @@ -410,26 +393,6 @@ fn sqlite_get_info( InfoType::MaxSchemaNameLen if conn.is_some_and(|c| !SqliteBackend::supports_schemas(c)) => { return Ok(InfoValue::U16(0)); } - // "Y": SQLite implements the whole Integrity Enhancement Facility -- - // PRIMARY KEY, UNIQUE, NOT NULL, CHECK, DEFAULT and FOREIGN KEY with - // referential actions -- and this build enforces all of it. Core - // defaults to "N", which is the right conservative answer for a data - // source without it and the wrong one here. - // - // Referential integrity in particular is enforced by construction, not - // by chance: the bundled library is compiled with - // SQLITE_DEFAULT_FOREIGN_KEYS, so `PRAGMA foreign_keys` is already on - // when a connection opens. Plain SQLite defaults it off for backward - // compatibility, so this claim is a property of *this* build. - // `integrity_enhancement_facility_is_actually_enforced` asserts that, - // and fails loudly if a dependency change ever takes the compile - // option away -- switching `rusqlite` off `bundled` to a system SQLite - // would. - // - // `SQLForeignKeys` is genuinely implemented (`metadata::foreign_keys`, - // over `PRAGMA foreign_key_list`), so an application that acts on this - // "Y" finds the metadata it then asks for. - InfoType::Integrity => return Ok(InfoValue::String("Y".into())), // Only SERIALIZABLE. "Transactions in SQLite are SERIALIZABLE", and // READ COMMITTED and REPEATABLE READ do not exist in SQLite at all. // @@ -451,11 +414,6 @@ fn sqlite_get_info( InfoType::TransactionIsolationProtocol => { return Ok(InfoValue::U32(SQL_TXN_SERIALIZABLE)); } - // SQL_TXN_CAPABLE is `An SQLUSMALLINT value` per the SQLGetInfo spec, - // not SQLUINTEGER -- found by the info-type conformance test - // (`stackable_odbc_core::conformance`). `SQL_TC_DML` is a small fixed constant - // (1), so the narrowing `as u16` cannot lose information. - InfoType::TransactionCapable => return Ok(InfoValue::U16(SQL_TC_DML as u16)), // SQL_GETDATA_EXTENSIONS is deliberately not answered here. It states // what core's own fetch path supports -- `sql_get_data` checks neither // column order nor binding state, and `sql_set_stmt_attr_w` substitutes @@ -629,6 +587,28 @@ pub(crate) const SQLITE_SUBQUERIES: u32 = /// `SQL_UNION` (96) — SQLite has both `UNION` and `UNION ALL`. pub(crate) const SQLITE_UNION: u32 = SQL_U_UNION | SQL_U_UNION_ALL; +/// `SQL_SPECIAL_CHARACTERS` (94) — the characters beyond `a`–`z`, `A`–`Z`, +/// `0`–`9` and `_` that may appear in an undelimited SQLite identifier. +/// +/// Just `$`. SQLite's tokenizer classifies it as an identifier character, so a +/// name containing it parses unquoted and round-trips through `sqlite_master` +/// unchanged. Every character in [`SPECIAL_CHARACTER_CANDIDATES`] is executed +/// against the bundled library by +/// `special_characters_are_each_live_probed`, which checks the rejected ones +/// too. +pub(crate) const SQLITE_SPECIAL_CHARACTERS: &str = "$"; + +/// The punctuation `special_characters_are_each_live_probed` tries in an +/// undelimited identifier: everything on a US keyboard that is not +/// alphanumeric or `_`. +/// +/// The probe asserts membership in [`SQLITE_SPECIAL_CHARACTERS`] both ways, so +/// this list is what stops that bitmap-equivalent from understating. A +/// character SQLite starts accepting shows up as a failure here rather than +/// going unnoticed. +#[cfg(test)] +pub(crate) const SPECIAL_CHARACTER_CANDIDATES: &str = "$#@!%^&*-+=./:?~`|\\'\"<>(){}[],;"; + /// `SQL_CONVERT_FUNCTIONS` (48) — SQLite's `CAST(x AS type)`. It has no /// ODBC `CONVERT` scalar function, so only the `CAST` bit is claimed. pub(crate) const SQLITE_CONVERT_FUNCTIONS: u32 = SQL_FN_CVT_CAST; @@ -1018,23 +998,23 @@ mod tests { ConnectParams, DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_AT_DROP_COLUMN_CASCADE, SQL_AT_DROP_COLUMN_DEFAULT, SQL_AT_DROP_COLUMN_RESTRICT, SQL_AT_DROP_TABLE_CONSTRAINT_CASCADE, SQL_AT_DROP_TABLE_CONSTRAINT_RESTRICT, - SQL_AT_SET_COLUMN_DEFAULT, SQL_CA1_NEXT, SQL_CB_PRESERVE, SQL_CN_ANY, - SQL_DRIVER_ODBC_VER_STRING, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, SQL_FN_NUM_FLOOR, - SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, SQL_FN_NUM_SQRT, - SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, + SQL_AT_SET_COLUMN_DEFAULT, SQL_CA1_NEXT, SQL_CA2_READ_ONLY_CONCURRENCY, SQL_CB_PRESERVE, + SQL_CN_ANY, SQL_DRIVER_ODBC_VER_STRING, SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, + SQL_FN_NUM_FLOOR, SQL_FN_NUM_LOG, SQL_FN_NUM_MOD, SQL_FN_NUM_POWER, SQL_FN_NUM_RAND, + SQL_FN_NUM_SQRT, SQL_FN_NUM_TRUNCATE, SQL_FN_STR_BIT_LENGTH, SQL_FN_STR_CHAR_LENGTH, SQL_FN_STR_CHARACTER_LENGTH, SQL_FN_STR_DIFFERENCE, SQL_FN_STR_INSERT, SQL_FN_STR_LEFT, SQL_FN_STR_LOCATE, SQL_FN_STR_LOCATE_2, SQL_FN_STR_POSITION, SQL_FN_STR_REPEAT, SQL_FN_STR_RIGHT, SQL_FN_STR_SPACE, SQL_FN_TD_DAYNAME, SQL_FN_TD_DAYOFMONTH, SQL_FN_TD_EXTRACT, SQL_FN_TD_MONTH, SQL_FN_TD_MONTHNAME, SQL_FN_TD_QUARTER, SQL_FN_TD_TIMESTAMPADD, SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_YEAR, SQL_GB_NO_RELATION, - SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_INSENSITIVE, - SQL_KEYWORDS, SQL_MAX_CURSOR_NAME_LEN, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_OIC_CORE, - SQL_SO_FORWARD_ONLY, SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, + SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, SQL_IC_MIXED, SQL_KEYWORDS, + SQL_MAX_CURSOR_NAME_LEN, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_OIC_CORE, SQL_SO_FORWARD_ONLY, + SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, SQL_SP_MATCH_UNIQUE_PARTIAL, SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, - SQL_TXN_SERIALIZABLE, + SQL_TXN_SERIALIZABLE, SQL_UNSPECIFIED, }; enum Expected { @@ -1084,9 +1064,11 @@ mod tests { (InfoType::AccessibleTables, Expected::Str("Y")), (InfoType::AccessibleProcedures, Expected::Str("N")), // "Y", not "N": SQLite implements and enforces the Integrity - // Enhancement Facility. See the arm in sqlite_get_info. + // Enhancement Facility. See Backend::integrity. (InfoType::Integrity, Expected::Str("Y")), - (InfoType::SpecialCharacters, Expected::Str("")), + // "$", not "": SQLite parses it inside an undelimited identifier. See + // special_characters_are_each_live_probed. + (InfoType::SpecialCharacters, Expected::Str(SQLITE_SPECIAL_CHARACTERS)), (InfoType::XopenCliYear, Expected::Str("1995")), (InfoType::CollationSeq, Expected::Str("")), (InfoType::DescribeParameter, Expected::Str("Y")), @@ -1126,7 +1108,14 @@ mod tests { // --- U32 values --- // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT -- see // the matching comment in stackable-odbc-core's default_get_info. - (InfoType::CursorSensitivity, Expected::U32(SQL_INSENSITIVE as u32)), + // + // SQL_UNSPECIFIED, not SQL_INSENSITIVE. This describes core's fetch + // path rather than SQLite, and core answers it: insensitivity is a + // promise that no other cursor's changes become visible, which core + // does not make about rows it has not read yet. Pinned here anyway, + // because the snapshot's job is the value an application sees + // regardless of which layer produced it. + (InfoType::CursorSensitivity, Expected::U32(SQL_UNSPECIFIED as u32)), // SQL_SQ_QUANTIFIED dropped: `< ALL` / `< ANY` / `< SOME` do not // parse, which SQL_SQL92_PREDICATES already recorded. Core's default // claimed it, so the two info types disagreed. @@ -1162,7 +1151,12 @@ mod tests { (InfoType::DynamicCursorAttributes1, Expected::U32(0)), (InfoType::DynamicCursorAttributes2, Expected::U32(0)), (InfoType::ForwardOnlyCursorAttributes1, Expected::U32(SQL_CA1_NEXT)), - (InfoType::ForwardOnlyCursorAttributes2, Expected::U32(0)), + // SQL_CA2_READ_ONLY_CONCURRENCY, not 0: core answers this, and reports + // the concurrency its one cursor actually offers. `SQLSetStmtAttr` + // accepts SQL_CONCUR_READ_ONLY unchanged and substitutes every other + // value back to it with 01S02, so 0 would deny a concurrency the + // driver had just accepted. + (InfoType::ForwardOnlyCursorAttributes2, Expected::U32(SQL_CA2_READ_ONLY_CONCURRENCY)), (InfoType::KeysetCursorAttributes1, Expected::U32(0)), (InfoType::KeysetCursorAttributes2, Expected::U32(0)), (InfoType::StaticCursorAttributes1, Expected::U32(0)), @@ -1190,11 +1184,12 @@ mod tests { } } + /// `SQL_DBMS_VER` is a per-connection `Backend` hook now, so it is read + /// through a connection rather than off the pre-connect path — which + /// cannot answer it, having no data source to name the version of. #[test] fn dbms_ver_is_well_formed() { - let InfoValue::String(s) = sqlite_get_info(None, InfoType::DbmsVer).unwrap() else { - panic!("expected String for DbmsVer"); - }; + let s = SqliteBackend::dbms_version(&test_connection()); let prefix = s.split(' ').next().unwrap_or(""); let parts: Vec<&str> = prefix.split('.').collect(); assert_eq!( @@ -1212,14 +1207,121 @@ mod tests { ); } + /// `SQL_QUOTED_IDENTIFIER_CASE` claims `SQL_IC_MIXED`, which asserts two + /// separate things about quoted identifiers: that they are matched + /// case-*insensitively*, and that the catalog stores them with the case + /// they were written in. Both are probed against the bundled library, + /// because the value this replaced (`SQL_IC_SENSITIVE`) was neither. + /// + /// A driver that claims `SQL_IC_SENSITIVE` here tells an application that + /// `"T"` and `"t"` are different tables. In SQLite they are the same one: + /// double quotes are a *delimiter*, letting a keyword or a name with + /// punctuation be used as an identifier, and they do not switch on + /// case-sensitive matching the way they do in a SQL-92 conformant DBMS. + #[test] + fn quoted_identifiers_are_not_case_sensitive() { + let conn = test_connection(); + let db = conn.conn.lock().unwrap(); + db.execute_batch(r#"CREATE TABLE "MixedCase" (a INTEGER);"#) + .unwrap(); + + // Case-insensitive: a differently-cased quoted name finds the table. + for spelling in [r#""mixedcase""#, r#""MIXEDCASE""#, r#""MiXeDcAsE""#] { + db.execute_batch(&format!("SELECT * FROM {spelling};")) + .unwrap_or_else(|e| { + panic!( + "SQLite resolved the quoted identifier {spelling} \ + case-sensitively ({e}), so SQL_QUOTED_IDENTIFIER_CASE \ + is not SQL_IC_MIXED" + ) + }); + } + + // Mixed *storage*: the catalog keeps the case it was created with, + // which is what separates SQL_IC_MIXED from SQL_IC_UPPER/SQL_IC_LOWER. + let stored: String = db + .query_row( + "SELECT name FROM sqlite_master WHERE type = 'table'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + stored, "MixedCase", + "SQLite folded a quoted identifier's stored case, so \ + SQL_QUOTED_IDENTIFIER_CASE is not SQL_IC_MIXED" + ); + + assert_eq!( + SqliteBackend::quoted_identifier_case(&conn), + SQL_IC_MIXED, + "the probe above says SQL_IC_MIXED" + ); + } + + /// Every character in [`SPECIAL_CHARACTER_CANDIDATES`] is executed inside + /// an undelimited identifier, and the outcome is asserted against + /// [`SQLITE_SPECIAL_CHARACTERS`] **both ways**. + /// + /// The negative half is the point, and is the same lesson + /// `alter_table_capabilities_are_each_live_probed` records: a list that is + /// only extended when someone notices can understate forever. `""` — core's + /// old default, inherited rather than chosen — was exactly that, and had an + /// application quoting `a$b`, a name SQLite parses bare. + /// + /// "Accepted" means more than "the CREATE parsed": the name must also come + /// back out of `sqlite_master` unchanged. A character the tokenizer treats + /// as punctuation could otherwise split the identifier and leave a + /// differently-named table behind, which would be a *worse* answer than + /// rejecting it. + #[test] + fn special_characters_are_each_live_probed() { + let conn = test_connection(); + let db = conn.conn.lock().unwrap(); + + for (index, ch) in SPECIAL_CHARACTER_CANDIDATES.chars().enumerate() { + // A distinct table per candidate, and the character in the middle + // so a leading-digit or leading-punctuation rule cannot be what is + // actually being measured. + let name = format!("probe{index}{ch}tail"); + let accepted = db + .execute_batch(&format!("CREATE TABLE {name} (a INTEGER);")) + .is_ok() + && db + .query_row( + "SELECT 1 FROM sqlite_master WHERE name = ?1", + [&name], + |r| r.get::<_, i64>(0), + ) + .is_ok(); + + let claimed = SQLITE_SPECIAL_CHARACTERS.contains(ch); + assert_eq!( + accepted, + claimed, + "SQL_SPECIAL_CHARACTERS {}claims {ch:?}, but the bundled \ + SQLite {} it in an undelimited identifier", + if claimed { "" } else { "does not " }, + if accepted { "accepts" } else { "rejects" }, + ); + } + + assert_eq!( + SqliteBackend::special_characters(&conn), + SQLITE_SPECIAL_CHARACTERS, + "the hook must report the probed list" + ); + } + /// SQL_DRIVER_VER is derived from Cargo.toml, so it cannot be asserted /// against a literal without reintroducing drift between the two. /// Assert the spec's shape instead. + /// + /// Unlike `SQL_DBMS_VER` this needs no connection: it describes the driver, + /// which the Windows Driver Manager asks about before one exists. #[test] fn driver_ver_is_well_formed() { - let InfoValue::String(v) = sqlite_get_info(None, InfoType::DriverVer).unwrap() else { - panic!("expected String for DriverVer"); - }; + let v = SqliteBackend::driver_version(); let parts: Vec<&str> = v.split('.').collect(); assert_eq!( parts.len(), diff --git a/src/backend/metadata.rs b/src/backend/metadata.rs index 2ebc325..ee56d4b 100644 --- a/src/backend/metadata.rs +++ b/src/backend/metadata.rs @@ -4,10 +4,11 @@ //! query helpers those functions share. use stackable_odbc_core::types::{ - ColumnRow, ForeignKeyRow, IdentifierType, Nullable, PrimaryKeyRow, SQL_CASCADE, - SQL_INDEX_OTHER, SQL_NO_ACTION, SQL_PC_NOT_PSEUDO, SQL_PC_PSEUDO, SQL_RESTRICT, - SQL_SET_DEFAULT, SQL_SET_NULL, SQL_TABLE_STAT, Scope, SpecialColumnRow, SqlDataType, - StatisticsRow, TableRow, + ColumnRow, ColumnsQuery, ForeignKeyRow, ForeignKeysQuery, IdentifierType, Nullable, + PrimaryKeyRow, PrimaryKeysQuery, SQL_CASCADE, SQL_INDEX_OTHER, SQL_NO_ACTION, + SQL_PC_NOT_PSEUDO, SQL_PC_PSEUDO, SQL_RESTRICT, SQL_SET_DEFAULT, SQL_SET_NULL, SQL_TABLE_STAT, + Scope, SpecialColumnRow, SpecialColumnsQuery, SqlDataType, StatisticsQuery, StatisticsRow, + TableRow, TablesQuery, }; /// Column indices for `PRAGMA table_info(table)`. @@ -168,12 +169,13 @@ fn build_column_row( i32::MAX }); - ColumnRow { - catalog: None, - schema: None, - table_name: table_name.to_string(), - column_name: col_name.to_string(), - data_type: sql_type.0, + // `catalog`, `schema`, `remarks` and `sql_datetime_sub` are left at their + // NULL default: SQLite has no catalogs or schemas, no column comments, and + // no datetime subcode to report. + ColumnRow::default() + .table_name(table_name) + .column_name(col_name) + .data_type(sql_type.0) // Spec (SQLColumns.TYPE_NAME / SQL_DESC_TYPE_NAME): both list bare // examples ("CHAR", "VARCHAR", ...), not declarations, so `col_type` // ("VARCHAR(50)") matches no `SQLGetTypeInfo` row. @@ -181,20 +183,17 @@ fn build_column_row( // execute.rs) returns the bare name that does; the declared length // is still carried above via COLUMN_SIZE (`precision`), just not the // name. - type_name: sqlite_bare_type_name(sql_type).to_string(), - column_size: Some(column_size), - buffer_length: Some(0), - decimal_digits: Some(scale), - num_prec_radix: if is_numeric { Some(10) } else { None }, - nullable: nullable.into(), - remarks: None, - column_def: dflt_value.map(str::to_string), - sql_data_type: sql_type.0, - sql_datetime_sub: None, - char_octet_length, - ordinal_position, - is_nullable: Some(nullable.as_is_nullable_str().to_string()), - } + .type_name(sqlite_bare_type_name(sql_type)) + .column_size(column_size) + .buffer_length(0) + .decimal_digits(scale) + .num_prec_radix(if is_numeric { Some(10) } else { None }) + .nullable(i16::from(nullable)) + .column_def(dflt_value.map(str::to_string)) + .sql_data_type(sql_type.0) + .char_octet_length(char_octet_length) + .ordinal_position(ordinal_position) + .is_nullable(nullable.as_is_nullable_str().to_string()) } /// Return the base tables to inspect: the exact named table if one is given, @@ -252,16 +251,25 @@ const TABLE_TYPE_VIEW: &str = "VIEW"; /// TABLE_TYPE, TABLE_CAT, TABLE_SCHEM, TABLE_NAME. pub(super) fn tables( conn: &SqliteConnection, - _catalog: Option<&str>, - _schema: Option<&str>, - table: Option<&str>, - table_type: Option<&str>, + query: &TablesQuery<'_>, ) -> Result<Vec<TableRow>, SqliteError> { // ODBC spec: empty string is a valid (but useless for SQLite) filter; treat // as no-filter. Treat "%" (match-all wildcard) as no-filter too, to avoid // LIKE '%' overhead -- an ordinary query is all that can arrive now. - let table = table.filter(|s| !s.is_empty() && *s != "%"); - let table_type = table_type.filter(|s| !s.is_empty() && *s != "%"); + let table = query.table().filter(|s| !s.is_empty() && *s != "%"); + + // `TableType` is a value list, not a pattern, and core has already split it + // on commas and stripped the optional single quotes -- so what arrives is + // the parsed values, with empty ones already dropped. A lone "%" still + // reaches here: the `SQL_ALL_TABLE_TYPES` enumeration core answers itself + // additionally requires the other three arguments to be empty strings, so + // "%" alongside a table pattern is an ordinary query. It is not a pattern + // either, and no table type is literally named "%", so read it as the + // no-filter an application sending "%" everywhere means. + let table_types: &[String] = match query.table_types() { + [only] if only == "%" => &[], + types => types, + }; let db = conn.conn.lock().map_err(|e| SqliteError::General { message: format!("Mutex poisoned: {e}"), @@ -291,21 +299,20 @@ pub(super) fn tables( TABLE_TYPE_TABLE }; - // Filter by table_type if specified (comma-separated list, not a pattern). - if let Some(tt) = table_type { - let allowed: Vec<&str> = tt.split(',').map(|s| s.trim().trim_matches('\'')).collect(); - if !allowed.iter().any(|a| a.eq_ignore_ascii_case(odbc_type)) { - continue; - } + // Filter by table type if the value list named any. + if !table_types.is_empty() + && !table_types + .iter() + .any(|a| a.eq_ignore_ascii_case(odbc_type)) + { + continue; } - rows.push(TableRow { - catalog: None, - schema: None, - name: Some(name), - table_type: Some(odbc_type.to_string()), - remarks: None, - }); + rows.push( + TableRow::default() + .name(name) + .table_type(odbc_type.to_string()), + ); } Ok(rows) @@ -313,14 +320,11 @@ pub(super) fn tables( pub(super) fn columns( conn: &SqliteConnection, - _catalog: Option<&str>, - _schema: Option<&str>, - table: Option<&str>, - column: Option<&str>, + query: &ColumnsQuery<'_>, ) -> Result<Vec<ColumnRow>, SqliteError> { // Same normalization as tables(): empty string and "%" both mean "no filter". - let table = table.filter(|s| !s.is_empty() && *s != "%"); - let column = column.filter(|s| !s.is_empty() && *s != "%"); + let table = query.table().filter(|s| !s.is_empty() && *s != "%"); + let column = query.column().filter(|s| !s.is_empty() && *s != "%"); let db = conn.conn.lock().map_err(|e| SqliteError::General { message: format!("Mutex poisoned: {e}"), @@ -395,16 +399,14 @@ pub(super) fn columns( /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlprimarykeys-function> pub(super) fn primary_keys( conn: &SqliteConnection, - _catalog: Option<&str>, - _schema: Option<&str>, - table: Option<&str>, + query: &PrimaryKeysQuery<'_>, ) -> Result<Vec<PrimaryKeyRow>, SqliteError> { let db = conn.conn.lock().map_err(|e| SqliteError::General { message: format!("Mutex poisoned: {e}"), })?; // Collect table names to query (either the specific one or all tables). - let table_names = tables_to_inspect(&db, table).map_err(map_sqlite_error)?; + let table_names = tables_to_inspect(&db, query.table()).map_err(map_sqlite_error)?; let mut result_rows: Vec<PrimaryKeyRow> = Vec::new(); for table_name in &table_names { @@ -436,17 +438,17 @@ pub(super) fn primary_keys( // and a second ordering in the backend is one more place for it to be // wrong. for (key_seq, col_name) in pk_cols { - result_rows.push(PrimaryKeyRow { - catalog: None, - schema: None, - table_name: table_name.clone(), - column_name: col_name, - key_seq: i16::try_from(key_seq).unwrap_or_else(|_| { - tracing::warn!(key_seq, "key sequence exceeds i16"); - i16::MAX - }), - pk_name: None, // not available in SQLite - }); + // `pk_name` is left NULL: SQLite does not record a primary key + // constraint name. + result_rows.push( + PrimaryKeyRow::default() + .table_name(table_name.clone()) + .column_name(col_name) + .key_seq(i16::try_from(key_seq).unwrap_or_else(|_| { + tracing::warn!(key_seq, "key sequence exceeds i16"); + i16::MAX + })), + ); } } @@ -461,19 +463,16 @@ pub(super) fn primary_keys( /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlforeignkeys-function> pub(super) fn foreign_keys( conn: &SqliteConnection, - _pk_catalog: Option<&str>, - _pk_schema: Option<&str>, - pk_table: Option<&str>, - _fk_catalog: Option<&str>, - _fk_schema: Option<&str>, - fk_table: Option<&str>, + query: &ForeignKeysQuery<'_>, ) -> Result<Vec<ForeignKeyRow>, SqliteError> { + let pk_table = query.pk_table(); + let db = conn.conn.lock().map_err(|e| SqliteError::General { message: format!("Mutex poisoned: {e}"), })?; // Which FK tables do we query? - let fk_table_names = tables_to_inspect(&db, fk_table).map_err(map_sqlite_error)?; + let fk_table_names = tables_to_inspect(&db, query.fk_table()).map_err(map_sqlite_error)?; let mut result_rows: Vec<ForeignKeyRow> = Vec::new(); @@ -524,25 +523,22 @@ pub(super) fn foreign_keys( }), }; - result_rows.push(ForeignKeyRow { - pk_catalog: None, - pk_schema: None, - pk_table_name: referenced_table, - pk_column_name, - fk_catalog: None, - fk_schema: None, - fk_table_name: fk_tbl.clone(), - fk_column_name: from_col, - key_seq: i16::try_from(seq + 1).unwrap_or_else(|_| { - tracing::warn!(seq, "key sequence exceeds i16"); - i16::MAX - }), // 1-based - update_rule: Some(fk_action_to_odbc(&on_update)), - delete_rule: Some(fk_action_to_odbc(&on_delete)), - fk_name: None, // not in SQLite's PRAGMA - pk_name: None, // not in SQLite's PRAGMA - deferrability: None, - }); + // `fk_name`, `pk_name` and `deferrability` are left NULL: SQLite's + // PRAGMA reports none of the three. + result_rows.push( + ForeignKeyRow::default() + .pk_table_name(referenced_table) + .pk_column_name(pk_column_name) + .fk_table_name(fk_tbl.clone()) + .fk_column_name(from_col) + // 1-based + .key_seq(i16::try_from(seq + 1).unwrap_or_else(|_| { + tracing::warn!(seq, "key sequence exceeds i16"); + i16::MAX + })) + .update_rule(fk_action_to_odbc(&on_update)) + .delete_rule(fk_action_to_odbc(&on_delete)), + ); } } @@ -587,16 +583,15 @@ fn parent_pk_column( /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlstatistics-function> pub(super) fn statistics( conn: &SqliteConnection, - _catalog: Option<&str>, - _schema: Option<&str>, - table: Option<&str>, - unique_only: bool, + query: &StatisticsQuery<'_>, ) -> Result<Vec<StatisticsRow>, SqliteError> { use stackable_odbc_core::types::{SQL_FALSE, SQL_TRUE}; + let unique_only = query.unique_only(); + // SQLStatistics.TableName cannot be a search pattern; an absent name has no // table to describe, so there are no rows to report. - let Some(table) = table.filter(|s| !s.is_empty()) else { + let Some(table) = query.table().filter(|s| !s.is_empty()) else { return Ok(Vec::new()); }; @@ -609,21 +604,12 @@ pub(super) fn statistics( // The table-stat row. TABLE_NAME and TYPE are the NOT NULL columns; // everything index-specific is NULL. - let mut rows: Vec<StatisticsRow> = vec![StatisticsRow { - catalog: None, - schema: None, - table_name: table.to_string(), - non_unique: None, - index_qualifier: None, - index_name: None, - index_type: SQL_TABLE_STAT, - ordinal_position: None, - column_name: None, - asc_or_desc: None, - cardinality, - pages: None, - filter_condition: None, - }]; + let mut rows: Vec<StatisticsRow> = vec![ + StatisticsRow::default() + .table_name(table) + .index_type(SQL_TABLE_STAT) + .cardinality(cardinality), + ]; // Enumerate indexes. Use the pragma_ TVF form so the name binds safely. let mut list_stmt = db @@ -679,30 +665,29 @@ pub(super) fn statistics( .get(pragma_index_xinfo_col::DESC) .map_err(map_sqlite_error)?; - rows.push(StatisticsRow { - catalog: None, - schema: None, - table_name: table.to_string(), - non_unique: Some(if *is_unique { - SQL_FALSE as i16 - } else { - SQL_TRUE as i16 - }), - index_qualifier: None, - index_name: Some(index_name.clone()), - index_type: SQL_INDEX_OTHER, - ordinal_position: Some(ordinal), - // "" for an expression index, which has no column name. - column_name: Some(col_name.unwrap_or_default()), - asc_or_desc: Some(if desc != 0 { "D" } else { "A" }.into()), - cardinality: None, - pages: None, - filter_condition: if *is_partial { - Some(String::new()) - } else { - None - }, - }); + // `index_qualifier`, `cardinality` and `pages` are left NULL: the + // first has no meaning without catalogs, and the latter two belong + // to the table-stat row above. + rows.push( + StatisticsRow::default() + .table_name(table) + .non_unique(if *is_unique { + SQL_FALSE as i16 + } else { + SQL_TRUE as i16 + }) + .index_name(index_name.clone()) + .index_type(SQL_INDEX_OTHER) + .ordinal_position(ordinal) + // "" for an expression index, which has no column name. + .column_name(col_name.unwrap_or_default()) + .asc_or_desc(if desc != 0 { "D" } else { "A" }.to_string()) + .filter_condition(if *is_partial { + Some(String::new()) + } else { + None + }), + ); } } @@ -735,22 +720,21 @@ fn table_cardinality_from_stat1(db: &rusqlite::Connection, table: &str) -> Optio /// the identifier's guaranteed scope yields an empty result set. /// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlspecialcolumns-function> +/// `query.nullable()` is not read: every identifier this reports is NOT NULL, +/// so the `Nullable` filter can never exclude a row. pub(super) fn special_columns( conn: &SqliteConnection, - identifier_type: IdentifierType, - _catalog: Option<&str>, - _schema: Option<&str>, - table: Option<&str>, - scope: Scope, - _nullable: Nullable, // our identifiers are all NOT NULL -> Nullable never filters + query: &SpecialColumnsQuery<'_>, ) -> Result<Vec<SpecialColumnRow>, SqliteError> { let empty = || Ok(Vec::new()); + let scope = query.scope(); + // ROWVER: SQLite has no auto-updated columns. - if matches!(identifier_type, IdentifierType::RowVer) { + if matches!(query.identifier_type(), IdentifierType::RowVer) { return empty(); } - let Some(table) = table.filter(|s| !s.is_empty()) else { + let Some(table) = query.table().filter(|s| !s.is_empty()) else { return empty(); }; @@ -863,17 +847,16 @@ fn special_column_row(name: &str, decl_type: &str, pseudo: i16, scope: Scope) -> let sql_type = sqlite_type_to_sql_data_type(decl_type); let column_size = i32::try_from(sqlite_declared_type_precision(decl_type)).unwrap_or(i32::MAX); let scale = sqlite_declared_type_scale(decl_type); - SpecialColumnRow { - scope: Some(scope.into()), - column_name: name.to_string(), - data_type: sql_type.0, - type_name: sqlite_bare_type_name(sql_type).to_string(), - column_size: Some(column_size), + SpecialColumnRow::default() + .scope(i16::from(scope)) + .column_name(name) + .data_type(sql_type.0) + .type_name(sqlite_bare_type_name(sql_type)) + .column_size(column_size) // BUFFER_LENGTH (approx: transfer octet length) - buffer_length: Some(column_size), - decimal_digits: if scale > 0 { Some(scale) } else { None }, - pseudo_column: Some(pseudo), - } + .buffer_length(column_size) + .decimal_digits(if scale > 0 { Some(scale) } else { None }) + .pseudo_column(pseudo) } /// Build one SQLSpecialColumns row for the 64-bit rowid pseudo-column / an @@ -881,16 +864,15 @@ fn special_column_row(name: &str, decl_type: &str, pseudo: i16, scope: Scope) -> fn special_column_row_bigint(name: &str, pseudo: i16, scope: Scope) -> SpecialColumnRow { let sql_type = SqlDataType::EXT_BIG_INT; let column_size = i32::try_from(default_precision_for_type(sql_type)).unwrap_or(i32::MAX); - SpecialColumnRow { - scope: Some(scope.into()), - column_name: name.to_string(), - data_type: sql_type.0, - type_name: sqlite_bare_type_name(sql_type).to_string(), - column_size: Some(column_size), - buffer_length: Some(8), // 8 bytes for a 64-bit integer - decimal_digits: None, // not applicable to integers - pseudo_column: Some(pseudo), - } + // `decimal_digits` is left NULL: not applicable to integers. + SpecialColumnRow::default() + .scope(i16::from(scope)) + .column_name(name) + .data_type(sql_type.0) + .type_name(sqlite_bare_type_name(sql_type)) + .column_size(column_size) + .buffer_length(8) // 8 bytes for a 64-bit integer + .pseudo_column(pseudo) } /// True if `table` is an ordinary rowid table. Probes `SELECT rowid`: a @@ -962,7 +944,7 @@ mod tests { #[test] fn tables_returns_all_tables_and_views() { let conn = setup_test_db(); - let rows = tables(&conn, None, None, None, None).unwrap(); + let rows = tables(&conn, &TablesQuery::default()).unwrap(); let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); for expected in ["empty_table", "types_test", "types_view", "parent", "child"] { @@ -992,7 +974,7 @@ mod tests { let conn = setup_test_db(); let declared: Vec<String> = table_types().iter().map(|t| t.to_string()).collect(); - let mut reported: Vec<String> = tables(&conn, None, None, None, None) + let mut reported: Vec<String> = tables(&conn, &TablesQuery::default()) .unwrap() .into_iter() .filter_map(|r| r.table_type) @@ -1011,7 +993,14 @@ mod tests { #[test] fn tables_filter_by_table_type() { let conn = setup_test_db(); - let rows = tables(&conn, None, None, None, Some(TABLE_TYPE_TABLE)).unwrap(); + // Core hands the backend the already-parsed value list, so the test + // supplies one rather than the raw comma-separated argument. + let only_tables = [TABLE_TYPE_TABLE.to_string()]; + let rows = tables( + &conn, + &TablesQuery::default().with_table_types(&only_tables[..]), + ) + .unwrap(); let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); assert!(names.contains(&"empty_table")); assert!(names.contains(&"types_test")); @@ -1021,7 +1010,7 @@ mod tests { #[test] fn tables_filter_by_name() { let conn = setup_test_db(); - let rows = tables(&conn, None, None, Some("types_test"), None).unwrap(); + let rows = tables(&conn, &TablesQuery::default().with_table("types_test")).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].name.as_deref(), Some("types_test")); } @@ -1032,7 +1021,7 @@ mod tests { // `empty\_table` with ESCAPE '\' means a literal underscore: matches // exactly "empty_table". Without ESCAPE the `_` is a wildcard and the // stray backslash matches nothing. - let rows = tables(&conn, None, None, Some("empty\\_table"), None).unwrap(); + let rows = tables(&conn, &TablesQuery::default().with_table("empty\\_table")).unwrap(); let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); assert_eq!(names, vec!["empty_table"]); } @@ -1044,7 +1033,16 @@ mod tests { // treats "%" as `SQL_ALL_TABLE_TYPES` when the other three arguments // are empty strings, so this reaches the backend as an ordinary query // and must list actual tables and views. - let rows = tables(&conn, Some(""), Some(""), Some("%"), Some("%")).unwrap(); + let percent = ["%".to_string()]; + let rows = tables( + &conn, + &TablesQuery::default() + .with_catalog("") + .with_schema("") + .with_table("%") + .with_table_types(&percent[..]), + ) + .unwrap(); let names: Vec<&str> = rows.iter().filter_map(|r| r.name.as_deref()).collect(); assert!( names.contains(&"types_test"), @@ -1055,7 +1053,7 @@ mod tests { #[test] fn columns_returns_correct_columns() { let conn = setup_test_db(); - let rows = columns(&conn, None, None, Some("types_test"), None).unwrap(); + let rows = columns(&conn, &ColumnsQuery::default().with_table("types_test")).unwrap(); let names: Vec<&str> = rows.iter().map(|r| r.column_name.as_str()).collect(); assert_eq!(names, vec!["id", "val", "label"]); // ORDINAL_POSITION is 1-based and is what core sorts on. @@ -1066,7 +1064,13 @@ mod tests { #[test] fn columns_filter_by_column_name() { let conn = setup_test_db(); - let rows = columns(&conn, None, None, Some("types_test"), Some("val")).unwrap(); + let rows = columns( + &conn, + &ColumnsQuery::default() + .with_table("types_test") + .with_column("val"), + ) + .unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].column_name, "val"); } @@ -1076,7 +1080,13 @@ mod tests { let conn = setup_test_db(); // types_test columns: id, val, label. "%l%" matches val and label. // Under the old exact-match filter this returned zero rows. - let rows = columns(&conn, None, None, Some("types_test"), Some("%l%")).unwrap(); + let rows = columns( + &conn, + &ColumnsQuery::default() + .with_table("types_test") + .with_column("%l%"), + ) + .unwrap(); let mut names: Vec<&str> = rows.iter().map(|r| r.column_name.as_str()).collect(); names.sort(); assert_eq!(names, vec!["label", "val"]); @@ -1088,7 +1098,7 @@ mod tests { // empty_table: id INTEGER PRIMARY KEY, name TEXT NOT NULL // SQLite PRAGMA table_info reports notnull=0 for INTEGER PRIMARY KEY (PK does not imply // NOT NULL in SQLite's PRAGMA), and notnull=1 for the explicit NOT NULL constraint. - let rows = columns(&conn, None, None, Some("empty_table"), None).unwrap(); + let rows = columns(&conn, &ColumnsQuery::default().with_table("empty_table")).unwrap(); assert_eq!(rows.len(), 2); let nullable_of = |name: &str| { @@ -1145,7 +1155,7 @@ mod tests { #[test] fn primary_keys_returns_pk_column() { let conn = setup_test_db(); - let rows = primary_keys(&conn, None, None, Some("parent")).unwrap(); + let rows = primary_keys(&conn, &PrimaryKeysQuery::default().with_table("parent")).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].table_name, "parent"); assert_eq!(rows[0].column_name, "pk"); @@ -1156,23 +1166,17 @@ mod tests { fn primary_keys_no_pk_returns_empty() { let conn = setup_test_db(); // types_test has no PRIMARY KEY constraint - let rows = primary_keys(&conn, None, None, Some("types_test")).unwrap(); + let rows = + primary_keys(&conn, &PrimaryKeysQuery::default().with_table("types_test")).unwrap(); assert!(rows.is_empty()); } #[test] fn foreign_keys_by_fk_table() { let conn = setup_test_db(); - let rows = foreign_keys( - &conn, - None, - None, - None, // pk table: unfiltered - None, - None, - Some("child"), // fk table: child - ) - .unwrap(); + // pk table unfiltered; fk table `child`. + let rows = + foreign_keys(&conn, &ForeignKeysQuery::default().with_fk_table("child")).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].pk_table_name, "parent"); @@ -1186,23 +1190,17 @@ mod tests { fn foreign_keys_no_fk_returns_empty() { let conn = setup_test_db(); // parent has no outgoing foreign keys - let rows = foreign_keys(&conn, None, None, None, None, None, Some("parent")).unwrap(); + let rows = + foreign_keys(&conn, &ForeignKeysQuery::default().with_fk_table("parent")).unwrap(); assert!(rows.is_empty()); } #[test] fn foreign_keys_by_pk_table() { let conn = setup_test_db(); - let rows = foreign_keys( - &conn, - None, - None, - Some("parent"), // pk table: parent - None, - None, - None, // fk table: unfiltered - ) - .unwrap(); + // pk table `parent`; fk table unfiltered. + let rows = + foreign_keys(&conn, &ForeignKeysQuery::default().with_pk_table("parent")).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].pk_table_name, "parent"); @@ -1225,7 +1223,7 @@ mod tests { .unwrap(); let conn = wrap(conn); - let rows = foreign_keys(&conn, None, None, None, None, None, Some("c")).unwrap(); + let rows = foreign_keys(&conn, &ForeignKeysQuery::default().with_fk_table("c")).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].pk_table_name, "p"); assert_eq!( @@ -1246,7 +1244,8 @@ mod tests { .unwrap(); let conn = wrap(conn); - let mut rows = foreign_keys(&conn, None, None, None, None, None, Some("c")).unwrap(); + let mut rows = + foreign_keys(&conn, &ForeignKeysQuery::default().with_fk_table("c")).unwrap(); rows.sort_by_key(|r| r.key_seq); assert_eq!(rows.len(), 2); assert_eq!( @@ -1273,7 +1272,7 @@ mod tests { #[test] fn statistics_reports_a_table_stat_row_and_one_row_per_index_key_column() { let conn = setup_stats_db(); - let rows = statistics(&conn, None, None, Some("t"), false).unwrap(); + let rows = statistics(&conn, &StatisticsQuery::new(false).with_table("t")).unwrap(); let stat_rows: Vec<&StatisticsRow> = rows .iter() @@ -1314,7 +1313,7 @@ mod tests { #[test] fn statistics_unique_only_drops_non_unique_indexes() { let conn = setup_stats_db(); - let rows = statistics(&conn, None, None, Some("t"), true).unwrap(); + let rows = statistics(&conn, &StatisticsQuery::new(true).with_table("t")).unwrap(); // table-stat row + the unique index's single column only. assert_eq!(rows.len(), 2); assert_eq!( @@ -1336,7 +1335,7 @@ mod tests { .unwrap() .execute_batch("CREATE TABLE plain (x INTEGER);") .unwrap(); - let rows = statistics(&conn, None, None, Some("plain"), false).unwrap(); + let rows = statistics(&conn, &StatisticsQuery::new(false).with_table("plain")).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].index_type, SQL_TABLE_STAT); } @@ -1345,7 +1344,7 @@ mod tests { fn statistics_with_no_table_returns_empty() { let conn = setup_stats_db(); assert!( - statistics(&conn, None, None, None, false) + statistics(&conn, &StatisticsQuery::new(false)) .unwrap() .is_empty() ); @@ -1361,7 +1360,7 @@ mod tests { .unwrap(); let conn = wrap(conn); - let rows = statistics(&conn, None, None, Some("tp"), false).unwrap(); + let rows = statistics(&conn, &StatisticsQuery::new(false).with_table("tp")).unwrap(); // table-stat row + a single index-column row: exactly one index. assert_eq!(rows.len(), 2); let index = rows @@ -1381,7 +1380,7 @@ mod tests { .unwrap(); let conn = wrap(conn); - let rows = statistics(&conn, None, None, Some("te"), false).unwrap(); + let rows = statistics(&conn, &StatisticsQuery::new(false).with_table("te")).unwrap(); // table-stat row + a single index-column row: exactly one index. assert_eq!(rows.len(), 2); let index = rows @@ -1407,12 +1406,12 @@ mod tests { let conn = setup_specialcols_db(); let rows = special_columns( &conn, - IdentifierType::BestRowId, - None, - None, - Some("with_int_pk"), - Scope::CurRow, - Nullable::SqlNullable, + &SpecialColumnsQuery::new( + IdentifierType::BestRowId, + Scope::CurRow, + Nullable::SqlNullable, + ) + .with_table("with_int_pk"), ) .unwrap(); @@ -1432,12 +1431,12 @@ mod tests { let conn = setup_specialcols_db(); let rows = special_columns( &conn, - IdentifierType::BestRowId, - None, - None, - Some("no_pk"), - Scope::CurRow, - Nullable::SqlNullable, + &SpecialColumnsQuery::new( + IdentifierType::BestRowId, + Scope::CurRow, + Nullable::SqlNullable, + ) + .with_table("no_pk"), ) .unwrap(); @@ -1453,12 +1452,12 @@ mod tests { let conn = setup_specialcols_db(); let rows = special_columns( &conn, - IdentifierType::BestRowId, - None, - None, - Some("without_rowid"), - Scope::CurRow, - Nullable::SqlNullable, + &SpecialColumnsQuery::new( + IdentifierType::BestRowId, + Scope::CurRow, + Nullable::SqlNullable, + ) + .with_table("without_rowid"), ) .unwrap(); @@ -1473,12 +1472,12 @@ mod tests { assert!( special_columns( &conn, - IdentifierType::RowVer, - None, - None, - Some("with_int_pk"), - Scope::CurRow, - Nullable::SqlNullable, + &SpecialColumnsQuery::new( + IdentifierType::RowVer, + Scope::CurRow, + Nullable::SqlNullable, + ) + .with_table("with_int_pk"), ) .unwrap() .is_empty() @@ -1493,12 +1492,12 @@ mod tests { assert!( special_columns( &conn, - IdentifierType::BestRowId, - None, - None, - Some("no_pk"), - Scope::Session, - Nullable::SqlNullable, + &SpecialColumnsQuery::new( + IdentifierType::BestRowId, + Scope::Session, + Nullable::SqlNullable, + ) + .with_table("no_pk"), ) .unwrap() .is_empty() diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 084a8cd..827f657 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -15,10 +15,10 @@ use stackable_odbc_core::{ EnvironmentAttribute, HandleType, HeaderDiagnosticIdentifier, InfoType, Nullable, Numeric, ParamType, SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, SQL_CASCADE, SQL_CD_FALSE, SQL_CURSOR_FORWARD_ONLY, SQL_DRIVER_ODBC_VER_STRING, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, - SQL_GD_BOUND, SQL_IC_SENSITIVE, SQL_INDEX_ALL, SQL_INDEX_OTHER, SQL_INDEX_UNIQUE, - SQL_QUICK, SQL_RESTRICT, SQL_TABLE_STAT, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, - SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, SqlReturn, StatementAttribute, - Timestamp, expected_kind, + SQL_GD_BOUND, SQL_IC_MIXED, SQL_INDEX_ALL, SQL_INDEX_OTHER, SQL_INDEX_UNIQUE, + SQL_MULTIPLE_ACTIVE_TXN, SQL_QUICK, SQL_RESTRICT, SQL_TABLE_STAT, SQL_TXN_READ_COMMITTED, + SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SqlDataType, + SqlReturn, StatementAttribute, Timestamp, expected_kind, }, }; @@ -647,7 +647,10 @@ unsafe fn assert_get_info_str(conn: *mut c_void, info_type: InfoType, expected: /// `odbc_sys::InfoType` variants, but `sqlite_get_info` has no arm for /// either and `default_get_info` doesn't cover them either; the only place /// that produces a real value for them is `common_get_info_raw`, reached -/// through the `get_info_raw` fallback in `sql_get_info_w`. +/// through the `get_info_raw` fallback in `sql_get_info_w`. The quoted case is +/// core answering from `Backend::quoted_identifier_case`, so this pins the +/// value an application sees no matter which layer produced it — see +/// `quoted_identifiers_are_not_case_sensitive` for why it is `SQL_IC_MIXED`. /// /// The ten capability bitmaps below (`AggregateFunctions`, `Sql92Predicates`, /// etc., computed by `SqliteBackend::get_info_raw` in `backend/info.rs`) are @@ -667,7 +670,31 @@ fn get_info_named_but_unhandled_types_fall_back_to_get_info_raw() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); assert_get_info_u16(conn, InfoType::SqlFileUsage, 0); - assert_get_info_u16(conn, InfoType::SqlQuotedIdentifierCase, SQL_IC_SENSITIVE); + assert_get_info_u16(conn, InfoType::SqlQuotedIdentifierCase, SQL_IC_MIXED); + + // SQL_MULTIPLE_ACTIVE_TXN has no `odbc_sys::InfoType` variant at all, + // so this raw path is the only way to reach it and the only place its + // value can be pinned -- `get_info_snapshot` iterates named types. + // "Y": each connection is its own rusqlite::Connection with its own + // SQLite handle, so two can have transactions open at once. See + // SqliteBackend::multiple_active_txn. + { + let mut buf = [0u16; 32]; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::<SqliteBackend>( + conn, + SQL_MULTIPLE_ACTIVE_TXN, + buf.as_mut_ptr() as *mut c_void, + 64, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "SQL_MULTIPLE_ACTIVE_TXN"); + assert_eq!( + String::from_utf16_lossy(&buf[..(str_len / 2) as usize]), + "Y", + "SQL_MULTIPLE_ACTIVE_TXN must be a \"Y\"/\"N\" string, not a number" + ); + } // SQLite capability bitmaps computed by SqliteBackend::get_info_raw // (backend/info.rs) -- reference the same constants that function @@ -4716,9 +4743,13 @@ fn get_info_every_named_info_type_has_the_declared_shape_connected() { for info_type in all_info_types() { let (ret, kind, _string_length) = observe_info_value_kind::<SqliteBackend>(conn, info_type as u16); - assert_eq!( + // Not `== SUCCESS`: the probe deliberately passes a non-null + // buffer of length 0, and core reports that as total truncation + // (`01004`, `SQL_SUCCESS_WITH_INFO`) for a string-shaped value. + // The property under test is that no info type errors. + assert_ne!( ret, - SqlReturn::SUCCESS, + SqlReturn::ERROR, "{info_type:?}: SQLGetInfoW must not return SQL_ERROR" ); assert_eq!( @@ -4749,9 +4780,11 @@ fn get_info_every_named_info_type_has_the_declared_shape_pre_connect() { for info_type in all_info_types() { let (ret, kind, _string_length) = observe_info_value_kind::<SqliteBackend>(conn, info_type as u16); - assert_eq!( + // See the connected test: a zero-length buffer is truncation, not + // failure. + assert_ne!( ret, - SqlReturn::SUCCESS, + SqlReturn::ERROR, "{info_type:?}: SQLGetInfoW must not return SQL_ERROR pre-connect" ); assert_eq!( From 088a8a0fd102d254c163bb4e978ba7769fb64ffa Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sat, 1 Aug 2026 23:04:48 +0200 Subject: [PATCH 29/50] docs: rewrite the README around the Stackable identity, and correct seven stale claims The README now follows the same shape as stackable-odbc-trino's: logo, badges, Stackable links line, then what the thing is before how to build it. It is written for a reader who does not already know what ODBC, a driver or SQLite is, since that is who arrives at a driver repository. Two departures from the Trino README, both because the alternative would be false here: it leads with building from source rather than a releases page, there being no tags yet, and every entry under Highlights names something the tests actually exercise. The stale claims, all verified against the code rather than assumed: - info.rs described MAX_FRACTIONAL_SECONDS_PRECISION as 0 in four places ("scale is fixed at 0", "'HH:MM:SS'", "column_size intentionally excludes a fractional-seconds allowance"). It is 3, and the test asserting default_precision_for_type(TIME) == 12 proves the fraction is budgeted. - The C ABI entry points are 60 SQL* functions plus ConfigDSNW on Windows, not 73. - The cancellation section's reason for having no query timeout, that the synchronous execute path has no deadline to arm, is falsified by core's query_timer.rs: QueryTimeout::CoreCancels arms one and calls Backend::cancel, which this driver implements for real. The behaviour is unchanged, so the text now records it as a gap with the preconditions for closing it. - params.rs is a doc-only stub; binding is inline in execute.rs. - CLAUDE.md's file sizes were off by up to 900 lines, and omitted backend.rs. - The Windows "Add" button is not inert: core exports ConfigDSNW and both installers register Setup=, so Add writes a DSN headlessly, silently missing Database. - Core's fetch benchmark moved to bench/benches/. Prose double dashes and em dashes are recast as commas, colons, full stops or parentheses throughout the comments and docs. backend.rs, execute.rs and escape_dialect.rs already used one style and info.rs and the FFI tests the other; now none of them use either. The SQL comments inside query literals and the banner rules are untouched. No CHANGELOG entry: nothing an application can observe changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .readme/static/borrowed/Icon_Stackable.svg | 20 ++ AGENTS.md | 118 +++++----- CLAUDE.md | 33 +-- README.md | 248 +++++++++++++++++---- benches/fetch_sqlite.rs | 16 +- packaging/README.md | 16 +- src/backend.rs | 88 ++++---- src/backend/execute.rs | 22 +- src/backend/info.rs | 183 +++++++-------- src/backend/metadata.rs | 16 +- src/ffi_integration_tests.rs | 106 ++++----- src/lib.rs | 4 +- src/type_conversion.rs | 26 +-- windows/WINDOWS.md | 16 +- 14 files changed, 561 insertions(+), 351 deletions(-) create mode 100644 .readme/static/borrowed/Icon_Stackable.svg diff --git a/.readme/static/borrowed/Icon_Stackable.svg b/.readme/static/borrowed/Icon_Stackable.svg new file mode 100644 index 0000000..35e132a --- /dev/null +++ b/.readme/static/borrowed/Icon_Stackable.svg @@ -0,0 +1,20 @@ +<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 507.97 517.33"> + <g> + <polygon points="0 44 0 115.99 254.37 71.99 254.37 0 0 44" style="fill: #0080bd"/> + <polygon points="0 325.52 0 397.52 254.37 353.52 254.37 281.53 0 325.52" style="fill: #0080bd"/> + <polygon points="254.37 72 507.97 113.59 507.97 41.61 254.37 0 254.37 72" style="fill: #b90069"/> + <polygon points="254.37 211.18 507.97 252.77 507.97 180.78 254.37 139.18 254.37 211.18" style="fill: #b90069"/> + <polygon points="254.37 517.33 507.96 517.33 507.96 461.14 254.37 419.55 254.37 517.33" style="fill: #b90069"/> + <polygon points="254.43 517.33 0.85 517.33 0.85 461.14 254.43 419.55 254.43 517.33" style="fill: #0080bd"/> + <polygon points="0.01 183.18 0.01 255.17 104.61 237.07 104.61 165.09 0.01 183.18" style="fill: #0080bd"/> + <polygon points="170.61 153.67 170.61 225.67 254.37 211.18 254.37 139.19 170.61 153.67" style="fill: #0080bd"/> + <polygon points="336.46 294.99 254.37 281.53 254.37 353.52 336.46 366.99 336.46 294.99" style="fill: #b90069"/> + <polygon points="507.97 113.59 403.19 96.4 403.19 163.59 507.97 113.59" style="fill: #245987"/> + <polygon points="507.97 252.77 403.19 235.58 403.19 302.62 507.97 252.77" style="fill: #245987"/> + <polygon points="507.97 394.84 403.19 377.65 403.19 444.19 507.97 394.84" style="fill: #245987"/> + <polygon points="0 115.99 104.61 165.09 104.5 97.92 0 115.99" style="fill: #245987"/> + <polygon points="0.01 255.17 104.61 307.49 104.61 236.48 0.01 255.17" style="fill: #245987"/> + <polygon points="0 397.52 104.61 444.11 104.5 379.52 0 397.52" style="fill: #245987"/> + <polygon points="507.97 394.84 507.97 319.8 403.19 302.62 403.19 377.65 507.97 394.84" style="fill: #b90069"/> + </g> +</svg> diff --git a/AGENTS.md b/AGENTS.md index cb33f81..fd32647 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ This crate is an ODBC driver for [SQLite](https://sqlite.org). It contains **only** SQLite-specific code: the `Backend` and `StatementBackend` implementations, connection-string parsing, SQLite-to-ODBC type conversion, ODBC escape-sequence translation, and the catalog and metadata functions. Everything -generic — handle management, UTF-16 marshalling, diagnostics, panic safety, and -the 73 C ABI entry points — lives in +generic (handle management, UTF-16 marshalling, diagnostics, panic safety, and +the C ABI entry points) lives in [`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core). ## Quick Reference @@ -46,8 +46,8 @@ published: stackable-odbc-core = { path = "../stackable-odbc-core" } ``` -There is a matching `TODO` in `Cargo.toml`. Until it is resolved, CI cannot pass -— a path dependency does not resolve on a runner. This crate is not published to +There is a matching `TODO` in `Cargo.toml`. Until it is resolved, CI cannot +pass, because a path dependency does not resolve on a runner. This crate is not published to crates.io; releases are GitHub Release archives built by `.github/workflows/release.yaml`. @@ -55,13 +55,13 @@ crates.io; releases are GitHub Release archives built by |---------|-------| | Handle allocation, tag validation, `panic_safe` | core | | UTF-16 marshalling, diagnostics, `SQLGetDiagRec` | core | -| The 73 exported C ABI entry points (`forward_ffi!`) | core | +| The exported C ABI entry points (`forward_ffi!`): 60 `SQL*` functions, plus `ConfigDSNW` on Windows | core | | `SQLGetInfo` marshalling and shape checking, cursor-state tracking | core | -| Every `SQLGetInfo` value that describes SQLite | this crate — see [Declaring capabilities](#declaring-capabilities) | +| Every `SQLGetInfo` value that describes SQLite | this crate, see [Declaring capabilities](#declaring-capabilities) | | `Backend` / `StatementBackend` trait definitions | core | | Opening the database, executing, fetching | this crate | | SQLite storage class → SQL type mapping, value conversion | this crate | -| Querying SQLite for catalog metadata | this crate — see [Catalog functions](#catalog-functions) | +| Querying SQLite for catalog metadata | this crate, see [Catalog functions](#catalog-functions) | | Catalog column layout, sort order, the `SQL_ALL_*` enumerations | core | | Connection-string parsing | this crate | | ODBC escape-sequence translation | this crate | @@ -81,8 +81,8 @@ method it calls. ### Changelog -Every change an application can observe — a reported `SQLGetInfo` value, a -SQLSTATE, a type mapping — gets an entry in `CHANGELOG.md` under +Every change an application can observe (a reported `SQLGetInfo` value, a +SQLSTATE, a type mapping) gets an entry in `CHANGELOG.md` under `## [Unreleased]`, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Internal refactoring does not. @@ -105,14 +105,14 @@ most easily, usually with the spec name relegated to a trailing comment. A comment is not a constant: ```rust -// BAD — the value is unchecked and the name is only a comment +// BAD: the value is unchecked and the name is only a comment sql_bind_parameter::<B>(stmt, 1, 1 /* SQL_PARAM_INPUT */, ..., -5 /* SQL_BIGINT */, ...); -// GOOD — the compiler validates both +// GOOD: the compiler validates both sql_bind_parameter::<B>(stmt, 1, ParamType::Input as i16, ..., SqlDataType::EXT_BIG_INT.0, ...); ``` -Prefer the `odbc-sys` type over defining a new constant when one exists — most +Prefer the `odbc-sys` type over defining a new constant when one exists. Most spec values are already modelled: | Value | Use | @@ -124,7 +124,7 @@ spec values are already modelled: | `SQL_HANDLE_*` | `HandleType::*` | All are re-exported from `stackable_odbc_core::types`. **This crate takes no -direct `odbc-sys` dependency** — it reaches those types only through core's +direct `odbc-sys` dependency**, reaching those types only through core's re-exports. Do not add `odbc-sys` to `Cargo.toml`. Core also re-exports the crate wholesale as `stackable_odbc_core::odbc_sys`, @@ -151,12 +151,12 @@ call site; that function is the single place that decides the SQLSTATE. `map_sqlite_error` keeps the `rusqlite::Error` it classified in the variant's `cause` field, and `From<SqliteError> for OdbcError` turns that into `with_native_error` (SQLite's *extended* result code, which is what separates -`SQLITE_CONSTRAINT_NOTNULL` from `SQLITE_CONSTRAINT_FOREIGNKEY` — the SQLSTATE +`SQLITE_CONSTRAINT_NOTNULL` from `SQLITE_CONSTRAINT_FOREIGNKEY`, as the SQLSTATE cannot) and `with_source` (the causal chain). A new classified variant must carry `cause` too, or it silently reports native code `0`. **One error type, both directions.** Every `Backend` and `StatementBackend` -method returns `Result<_, SqliteError>` — core requires +method returns `Result<_, SqliteError>`, because core requires `Into<OdbcError> + From<OdbcError> + Error + Send + Sync + 'static`. The `From<OdbcError>` direction is what lets a defaulted trait body construct an error and still name `Self::Error`, and `SqliteError::Odbc` is where such an @@ -165,13 +165,13 @@ rather than reclassifying them: the round trip is lossless, and reclassifying would discard the SQLSTATE core chose. Convert raw integers to typed enums at the boundary with the `xxx_from_raw()` -functions from core — never `transmute`. +functions from core, never `transmute`. ### 08001 versus 08S01 `08001` ("client unable to establish connection") is only valid from the connection functions. Once a connection exists, a failing link is `08S01` -("communication link failure") — that is the code the diagnostics tables of +("communication link failure"). That is the code the diagnostics tables of `SQLExecute`, `SQLFetch`, `SQLGetInfo` and the rest actually list. For this driver `connect` is where real I/O happens: @@ -181,7 +181,7 @@ database file is `08001`. Failures after that point are `08S01`. ### Declaring capabilities `Backend` has around thirty **required** methods that state what SQLite can -do — `alter_table_support`, `outer_join_capabilities`, `subqueries`, +do: `alter_table_support`, `outer_join_capabilities`, `subqueries`, `sql_conformance`, `supports_catalogs`, `identifier_case`, `quoted_identifier_case`, `txn_capable`, `txn_isolation_options`, `integrity`, `multiple_active_txn`, `special_characters`, `accessible_procedures`, @@ -192,26 +192,26 @@ error. `table_types` is required for the same reason and one of its own: an empty table-type list is an *answer* ("this data source has no table types"), not "unknown", and unlike catalogs and schemas there is no `supports_*` method for core to derive it from. `special_characters` is required on that same -principle — `""` asserts that nothing beyond the alphanumerics and underscore +principle: `""` asserts that nothing beyond the alphanumerics and underscore is legal unquoted, which is a claim, not an absence, and inheriting it as a default is how this driver came to under-report `$`. They all take `&Self::Connection`, because `SQLGetInfo` is a per-connection call and a data source's capabilities can differ by server. Every one this driver declares is a property of the SQLite `rusqlite` links, not of the file -opened, so each ignores the argument — but the answer must still be read +opened, so each ignores the argument, but the answer must still be read through a connection, and the tests do that via `info::tests::test_connection` rather than calling the hook as a free function. `cursor_commit_behavior`, `cursor_rollback_behavior`, `catalog_result_column_widths`, `driver_name` and `driver_version` are the exceptions and take none: `SQLGetInfo` must answer the first three before a connection exists, and the Windows Driver Manager asks for driver identity before `SQLDriverConnectW`. Note the split within the identity -group — `driver_name`/`driver_version` describe the driver and take no +group: `driver_name`/`driver_version` describe the driver and take no connection, while `dbms_name`/`dbms_version` describe what was connected to and take one. The same split runs through `get_info`. `sqlite_get_info` takes -`Option<&SqliteConnection>` — `None` on the pre-connect path — and hands it to +`Option<&SqliteConnection>` (`None` on the pre-connect path) and hands it to `default_get_info` / `common_get_info_raw`, which answer only what is knowable without a data source and leave the rest. An arm that consults a capability hook must therefore be guarded on the connection being present, which is why @@ -221,11 +221,11 @@ one is open. Four rules, all learned the hard way: **Declare it once.** A capability with a hook is answered *only* through the -hook — never also in `get_info_raw`. Core derives the info type from the hook, +hook, never also in `get_info_raw`. Core derives the info type from the hook, so a second answer is a value that can disagree with itself, and the one an application sees depends on which core consults first. `SQL_IDENTIFIER_CASE` was stated in both places; so was `SQL_GETDATA_EXTENSIONS`, which is not even a -fact about SQLite — it describes core's own fetch path, and belongs to core for +fact about SQLite: it describes core's own fetch path, and belongs to core for the same reason. The snapshot test (`get_info_snapshot`) pins the value an application sees regardless of who answers it, which is what makes moving an answer safe. @@ -258,7 +258,7 @@ one capability stated twice, in opposite directions: | `SQL_TXN_ISOLATION_OPTION` with four levels | nothing applying the level an application sets | When adding or changing a capability, look for the other info type that talks -about the same thing, and assert the relationship — +about the same thing, and assert the relationship. `catalog_and_schema_info_types_agree_with_each_other` and `transaction_isolation_offers_only_the_level_sqlite_implements` are that check, and they assert the spec's rule rather than today's values, so they keep @@ -268,7 +268,7 @@ holding if the answer changes. `connect` issues `PRAGMA foreign_keys = ON`. SQLite leaves it off for backward compatibility, and the bundled library only happens to compile with -`SQLITE_DEFAULT_FOREIGN_KEYS` — so without the pragma, `SQL_INTEGRITY = "Y"` +`SQLITE_DEFAULT_FOREIGN_KEYS`, so without the pragma `SQL_INTEGRITY = "Y"` would depend on a dependency's build flags rather than on this driver. `integrity_enhancement_facility_is_actually_enforced` checks it through `connect`. @@ -282,13 +282,13 @@ mode. Both `cursor_commit_behavior` and `cursor_rollback_behavior` return `CursorBehavior::Preserve`, and **this depends on an implementation detail**: `execute::exec_direct` materialises every result set eagerly, so no -`rusqlite::Statement` is live when `end_tran` runs. Raw SQLite is stricter — a +`rusqlite::Statement` is live when `end_tran` runs. Raw SQLite is stricter: a ROLLBACK aborts pending statements with `SQLITE_ABORT` (>= 3.7.11), which would be `SQL_CB_CLOSE`, and a COMMIT with pending writes fails with `SQLITE_BUSY`. If result sets ever become lazily streamed, both hooks must be revisited, and `SQL_CB_CLOSE` would additionally require a real -`StatementBackend::close_cursor` — which is fallible now (`Result<(), +`StatementBackend::close_cursor`, which is fallible now (`Result<(), Self::Error>`), because under `SQL_CB_CLOSE` it is the only thing that closes the cursor during `SQLEndTran`, and a failure has to reach the statement's diagnostic queue rather than be swallowed. Here it only resets an index into an @@ -299,7 +299,7 @@ reported values through the FFI entry point. `SQL_ATTR_TXN_ISOLATION` is validated by core against `txn_isolation_options`, which this driver answers with `SQL_TXN_SERIALIZABLE` alone. Setting any other level on an open connection is refused with `HY024` rather than stored and -echoed back — see `txn_isolation_accepts_only_the_level_sqlite_implements`. +echoed back. See `txn_isolation_accepts_only_the_level_sqlite_implements`. Because `txn_isolation_options` is a per-connection hook, a level set *before* connecting is only checked for naming exactly one level; the comparison against the hook happens at connect time, so an unsupported level fails the connect. @@ -311,12 +311,12 @@ and `cancel` calls `sqlite3_interrupt`, which stops the in-flight `sqlite3_step` on that connection. This is the **aliasing** token shape of the two `Backend::CancelToken`'s doc -comment describes — the token refers to the same connection the statement is -executing on — and it is sound only because SQLite documents +comment describes (the token refers to the same connection the statement is +executing on), and it is sound only because SQLite documents `sqlite3_interrupt` as safe to call from another thread. The `Arc` is core's requirement for that shape: core clones the token out of its registry before touching anything else, so the token has to survive a concurrent -`SQLDisconnect`. `rusqlite` already satisfies the underlying rule — its +`SQLDisconnect`. `rusqlite` already satisfies the underlying rule: its `InterruptHandle` holds an `Arc<Mutex<*mut sqlite3>>` shared with the connection, and `InnerConnection::close` nulls that pointer while holding the same mutex, so a racing `interrupt()` either finds a live handle or finds null @@ -326,7 +326,7 @@ Three things this depends on, in order: - **The handle is captured in `connect`,** not fetched on demand. `cancel_token` can neither block nor fail, and the `rusqlite::Connection` - lives behind a `Mutex` — reaching through it would mean waiting on whatever + lives behind a `Mutex`, so reaching through it would mean waiting on whatever thread is executing. Core's own doc asks for the same thing for a different reason: assemble the token with the connection in hand, never lazily inside `cancel`. @@ -342,13 +342,25 @@ Three things this depends on, in order: `sql_cancel_from_another_thread_stops_a_running_statement` drives the real entry points across two threads. It was verified by mutation: with `token.interrupt()` removed the query runs to completion and the test fails on -the return code. Note the gate it holds — `SQLCancel`'s idle branch clears the +the return code. Note the gate it holds: `SQLCancel`'s idle branch clears the statement's diagnostic queue, so a cancel landing after `SQLExecDirectW` returns would wipe the `HY008` the test is reading. -`SQL_ATTR_QUERY_TIMEOUT` is still substituted with `0` and reported as `01S02`. -Cancellation is a signal from another thread; a timeout would need a deadline -this driver's synchronous execute path has nothing to arm. +`SQL_ATTR_QUERY_TIMEOUT` is still substituted with `0` and reported as `01S02`, +because this driver does not override `Backend::set_query_timeout` and the +default answers `NotImplemented`. + +**That is now a gap rather than an impossibility.** The original reason (a +synchronous execute path with no deadline to arm) no longer holds: core owns +the timer (`query_timer.rs`), and `Ok(QueryTimeout::CoreCancels)` asks it to arm +one and call `Backend::cancel` when the deadline passes. `cancel` is real here, +which is exactly the precondition `CoreCancels` documents. Closing the gap means +overriding `set_query_timeout` to return `CoreCancels`, and overriding +`is_cancelled` alongside it, since that is what turns the interrupted statement's +own symptom into the `HYT00` the application is waiting for rather than the +`HY008` a user-initiated `SQLCancel` produces. `SQL_ATTR_QUERY_TIMEOUT` is a +*statement* attribute while the hook receives only the connection, so read +core's scope caveat on `set_query_timeout` before doing it. ## Architecture of this crate @@ -359,7 +371,7 @@ this driver's synchronous execute path has nothing to arm. | `src/backend/execute.rs` | `exec_direct`, `prepare`, `execute`, and the `StatementBackend` impl | | `src/backend/info.rs` | `SQLGetInfo` answers and the capability bitmaps, plus the snapshot test | | `src/backend/metadata.rs` | The catalog row producers: tables, columns, primary keys, statistics, special columns | -| `src/backend/params.rs` | Parameter binding | +| `src/backend/params.rs` | Deliberately empty. Parameter binding is inline in `execute.rs`; the entry points are core's | | `src/backend/types/connect_params.rs` | `SqliteConnectParams` | | `src/escape_dialect.rs` | ODBC escape-sequence translation for SQLite's dialect | | `src/type_conversion.rs` | SQLite storage classes and declared types → ODBC SQL types | @@ -367,7 +379,7 @@ this driver's synchronous execute path has nothing to arm. ### Result sets are materialised eagerly -`SqliteStatement` holds `rows: Vec<Vec<ColumnValue>>` and `cursor: i64` — an +`SqliteStatement` holds `rows: Vec<Vec<ColumnValue>>` and `cursor: i64`, an index into an in-memory snapshot, not a live SQLite cursor. `exec_direct` collects every row before returning and the `rusqlite::Statement` is finalized at that point. @@ -393,8 +405,8 @@ with **zero columns** reporting **`Some(0)`** into `SQL_NO_DATA`, which is delete statement that doesn't affect any rows". Answering `Some(0)` for DDL therefore made every `CREATE TABLE` return `SQL_NO_DATA`. -SQLite offers no predicate for "is this DML" — `sqlite3_stmt_readonly` is false -for DDL too — so `execute::is_searched_dml` decides it from the statement's +SQLite offers no predicate for "is this DML" (`sqlite3_stmt_readonly` is false +for DDL too), so `execute::is_searched_dml` decides it from the statement's leading keyword, past whitespace and both comment forms. `REPLACE` and `WITH` count alongside the obvious three: the first is an `INSERT OR REPLACE` alias, and the second fronts a CTE, which is only ever consulted for a zero-column @@ -410,11 +422,11 @@ handed that `3`. `ddl_after_dml_does_not_inherit_the_dml_row_count` pins it. ### Catalog functions -The six catalog methods take a **typed query object** — `&TablesQuery`, +The six catalog methods take a **typed query object** (`&TablesQuery`, `&ColumnsQuery`, `&PrimaryKeysQuery`, `&ForeignKeysQuery`, `&StatisticsQuery`, -`&SpecialColumnsQuery` — and return **typed row vectors** — `Vec<TableRow>`, +`&SpecialColumnsQuery`) and return **typed row vectors** (`Vec<TableRow>`, `Vec<ColumnRow>`, `Vec<PrimaryKeyRow>`, `Vec<ForeignKeyRow>`, -`Vec<StatisticsRow>`, `Vec<SpecialColumnRow>` — not a `Self::Statement`. Core +`Vec<StatisticsRow>`, `Vec<SpecialColumnRow>`), not a `Self::Statement`. Core converts each row to the spec's column layout, sorts the set into the order that function's spec page mandates, and serves it. @@ -425,7 +437,7 @@ Both sides are core's types and both are sealed, which is what a change in `#[non_exhaustive]`, so a row is built from `Default` and the consuming setter per column: `TableRow::default().name(n).table_type(t)`. Each setter takes `impl Into<T>`, so an `Option<String>` column accepts a bare `String`. - A column a driver does not populate is simply not named — which is the point, + A column a driver does not populate is simply not named, which is the point, since it makes a column added to a spec result set a core-only change instead of a break in every driver. The query types are sealed the same way, with crate-private fields, an accessor and a `with_*` setter per field, and a @@ -439,8 +451,8 @@ Both sides are core's types and both are sealed, which is what a change in trait boundary reintroduces that hazard one layer down, so the query travels all the way into `metadata.rs`. - **`TablesQuery::table_types()` is already parsed.** Core splits `TableType` - on commas and strips the optional single quotes — it is a value list, not a - pattern, and `SQL_ATTR_METADATA_ID` never applies to it — so a backend gets a + on commas and strips the optional single quotes (it is a value list, not a + pattern, and `SQL_ATTR_METADATA_ID` never applies to it), so a backend gets a `&[String]` and never parses it. Empty means no filter. A lone `"%"` does still arrive, because the `SQL_ALL_TABLE_TYPES` enumeration core answers itself additionally requires the other three arguments to be empty strings; @@ -450,7 +462,7 @@ Three further consequences for anything changed in `metadata.rs`: - **Do not sort, and do not add an `ORDER BY` for ODBC's sake.** Core sorts, stably, on the spec's keys. A second ordering in the backend is one more - place for it to be wrong, and it silently overrides nothing — core re-sorts + place for it to be wrong, and it silently overrides nothing: core re-sorts regardless. The one thing to keep in mind is that the sort takes NULL placement from `Backend::null_collation`, which is why `SQLStatistics`' table-stat row (NULL `NON_UNIQUE`) still comes first: this driver reports @@ -464,12 +476,12 @@ Three further consequences for anything changed in `metadata.rs`: say SQLite has neither, so core never asks. - **A non-`Option` field is a column the spec marks "not NULL".** The types enforce it, which is how `SQLForeignKeys`' `PKCOLUMN_NAME` stopped being - reported as NULL for a `REFERENCES parent` with no column list — SQLite + reported as NULL for a `REFERENCES parent` with no column list. SQLite defines that as the parent's primary key, so `parent_pk_column` resolves the name rather than dropping it. Because ordering is core's, an ordering assertion belongs in -`ffi_integration_tests.rs`, where core's sort has actually run — the unit tests +`ffi_integration_tests.rs`, where core's sort has actually run. The unit tests in `metadata.rs` assert only which rows exist and what each field holds. See `sql_statistics_w_orders_table_stat_row_first_then_unique_before_non_unique`. @@ -495,7 +507,7 @@ prompt for it. cargo test ``` -Needs no database file — the FFI tests connect to `:memory:`. `cargo test` runs +Needs no database file: the FFI tests connect to `:memory:`. `cargo test` runs both the per-module unit tests and `src/ffi_integration_tests.rs`, which drives the real exported entry points against real handles. Prefer adding to the FFI tests when the behaviour is observable by an application: they catch the @@ -508,7 +520,7 @@ otherwise ship inside the driver binary. **Set up test data through the FFI, not by reaching into the handle.** Core's `handles` module is `pub(crate)`, so `ConnectionHandle` and the -`rusqlite::Connection` inside it are no longer reachable from here — use the +`rusqlite::Connection` inside it are no longer reachable from here, so use the `setup_sql`, `query_scalar_i64` and `query_row_two_strings` helpers, which go through `SQLExecDirect`/`SQLFetch`/`SQLGetData`. Each allocates its own statement handle rather than borrowing the caller's, because the statement a @@ -550,7 +562,7 @@ the `SqliteBackend` → `ColumnValue` → `write_column_value` pipeline. ### What runs in core, not here -Do not reintroduce these — they moved with the framework: +Do not reintroduce these; they moved with the framework: - **Miri.** The driver crates link C libraries (bundled SQLite) that Miri cannot execute. Core is pure Rust and holds the raw-pointer marshalling. diff --git a/CLAUDE.md b/CLAUDE.md index 63945d9..67a369a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Project Rules -Read and follow @AGENTS.md — it contains architecture, patterns, and procedures. +Read and follow @AGENTS.md, which contains architecture, patterns, and procedures. ## Non-Negotiable Rules @@ -10,7 +10,7 @@ Read and follow @AGENTS.md — it contains architecture, patterns, and procedure `get_info_raw`, the catalog functions and the type-conversion paths is directly observable by applications, and each has a spec-defined shape and value range. Never claim a SQLSTATE or an info value is wrong without checking - the actual spec table first. Pay attention to **(DM)** annotations — those + the actual spec table first. Pay attention to **(DM)** annotations: those SQLSTATEs are returned by the Driver Manager, not the driver. - **Route every client error through `map_sqlite_error`.** Never hand-build an `OdbcError` or `SqliteError` from a `rusqlite::Error` at the call site; that @@ -19,17 +19,17 @@ Read and follow @AGENTS.md — it contains architecture, patterns, and procedure diagnostic reports native code `0`. - **One error type.** Every `Backend` and `StatementBackend` method returns `Result<_, SqliteError>`. An `OdbcError` core produced travels back through - `SqliteError::Odbc` via `.into()` — never reclassify it, which would discard + `SqliteError::Odbc` via `.into()`. Never reclassify it, which would discard the SQLSTATE core chose. - **Declare each capability once.** A `SQLGetInfo` value with a `Backend` hook is answered through the hook only, never also in `get_info_raw`. Two answers are a value that can disagree with itself. -- **Use `odbc-sys` types** — never redefine enums, structs, or constants it +- **Use `odbc-sys` types.** Never redefine enums, structs, or constants it already provides. Reach them through `stackable_odbc_core::types`, or through `stackable_odbc_core::odbc_sys` for anything `types` does not re-export. Do **not** add `odbc-sys` as a direct dependency, and do not hand-roll a `#[repr(C)]` mirror of one of its structs. -- **Convert raw integers to typed enums at the boundary** — use the +- **Convert raw integers to typed enums at the boundary.** Use the `xxx_from_raw()` functions from core, never `transmute`. - **Do not make result-set fetching lazy.** `exec_direct` materialises every row before returning, and two reported ODBC capabilities @@ -48,20 +48,21 @@ Read and follow @AGENTS.md — it contains architecture, patterns, and procedure Never read entire files by default. Survey, locate, then extract. -1. **Survey first** — check file size before reading (`stat -c%s file`). Files - >50 KB must be sliced, not read whole. `src/ffi_integration_tests.rs` (~4600 - lines), `src/backend/metadata.rs` (~1700), `src/backend/info.rs` (~1600) and - `src/type_conversion.rs` (~1000) are all well over that. -2. **Navigate definitions with ctags** — run `ctags -R .` once to build a tags +1. **Survey first.** Check file size before reading (`stat -c%s file`). Files + >50 KB must be sliced, not read whole. `src/ffi_integration_tests.rs` (~5100 + lines), `src/backend/info.rs` (~2500), `src/backend.rs` (~1700), + `src/backend/metadata.rs` (~1500) and `src/type_conversion.rs` (~1000) are + all well over that. +2. **Navigate definitions with ctags.** Run `ctags -R .` once to build a tags index, then `grep "^SymbolName" tags` to find the exact file and line of any - function, struct, or trait — no file reading needed. -3. **Locate with Grep** — find patterns, keywords, or usages before reading. Use + function, struct, or trait, with no file reading needed. +3. **Locate with Grep.** Find patterns, keywords, or usages before reading. Use `-C` for context lines. -4. **Extract with Read (offset + limit)** — once you know the line range, read +4. **Extract with Read (offset + limit).** Once you know the line range, read only that slice. -5. **Structured data** — use `jq` for JSON, `yq` for YAML; never read raw markup +5. **Structured data.** Use `jq` for JSON, `yq` for YAML; never read raw markup whole. -6. **Filesystem survey** — use `tree -L 2 -I '.git|target|node_modules'` instead +6. **Filesystem survey.** Use `tree -L 2 -I '.git|target|node_modules'` instead of recursive `ls`. -7. **Verify edits with diff** — after editing, `git diff -u` to confirm changes +7. **Verify edits with diff.** After editing, `git diff -u` to confirm changes instead of re-reading. diff --git a/README.md b/README.md index 8c4b51b..070f890 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,246 @@ -# stackable-odbc-sqlite +<!-- markdownlint-disable MD041 MD033 --> -ODBC 3.x driver for [SQLite](https://sqlite.org), built on the -[stackable-odbc-core](https://github.com/stackabletech/stackable-odbc-core) -framework. +<p align="center"> + <img width="150" src="./.readme/static/borrowed/Icon_Stackable.svg" alt="Stackable Logo"/> +</p> -The driver compiles to a C dynamic library that an ODBC Driver Manager -(unixODBC on Linux, the built-in Driver Manager on Windows) loads at runtime. -It opens a local SQLite database file through `rusqlite` with the bundled -SQLite library, so it needs no server and no external SQLite installation. +<h1 align="center">Stackable ODBC Driver for SQLite</h1> -## Requirements +<p align="center"><em>Open a SQLite file from Excel, DBeaver, LibreOffice or Python, with no server to run.</em></p> -- Rust 1.95.0+ (pinned in `rust-toolchain.toml`) -- Linux: `unixODBC` and `isql` (`pacman -S unixodbc-dev` / `apt install unixodbc-dev`) -- `sqlite3` CLI for creating the test database (`pacman -S sqlite` / `apt install sqlite3`) +[![Build and Test](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/build.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/build.yaml) +[![Security Audit](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/security_audit.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/security_audit.yaml) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](https://docs.stackable.tech/home/stable/contributor/index.html) +[![Apache License 2.0](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE) +[![ODBC 3.80](https://img.shields.io/badge/ODBC-3.80-blue)](#what-it-deliberately-does-not-do) +[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20Windows-blue)](#quick-start) +[![SQLite bundled](https://img.shields.io/badge/SQLite-3.53.2%20bundled-blue)](https://sqlite.org) -## Building +[Stackable Data Platform](https://stackable.tech/) | [Platform Docs](https://docs.stackable.tech/) | [Discussions](https://github.com/orgs/stackabletech/discussions) | [Discord](https://discord.gg/7kZ3BNnCAF) + +## What is this? + +[SQLite](https://sqlite.org) is a database that lives in a single file. There +is nothing to install and nothing to start: the whole database is one `.db` +file you can copy onto a USB stick. Your phone is running several of them right +now. + +Most desktop tools cannot open one of those files directly, but nearly all of +them speak **ODBC**. ODBC is a widely supported standard: a tool loads a small library called a *driver*, calls a fixed set of functions on it, and the driver translates those calls into whatever the actual database understands. Write one driver, and every ODBC-speaking tool on the machine can talk to that database. + +This repository is that driver for SQLite. Install it, and Excel, LibreOffice +Base, DBeaver, `isql` and Python's `pyodbc` can query a SQLite file as if it +were a full database server. Linux and Windows are both supported. + +Two things make it unusual: + +- **It carries its own SQLite.** Version 3.53.2 is compiled straight into the + driver, so there is no separate SQLite to install and no version of it on the + machine that could disagree with the one the driver actually uses. +- **It is a testbed.** Everything generic about being an ODBC driver lives in + [`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core), + which also powers the + [Trino driver](https://github.com/stackabletech/stackable-odbc-trino). SQLite + is small, fast and needs no server, which makes it the ideal backend for + proving that shared framework behaves. + +## Quick start + +No release has been cut yet, so build the driver yourself. You need Rust (the +version in `rust-toolchain.toml` is installed automatically by `rustup`) and +the unixODBC development headers, because the ODBC bindings link against them: + +```bash +sudo apt-get install unixodbc-dev # Debian/Ubuntu +sudo pacman -S unixodbc # Arch +``` + +Clone this repository: ```bash -cargo build +git clone https://github.com/stackabletech/stackable-odbc-sqlite +cd stackable-odbc-sqlite +cargo build --release ``` -Linux output: `target/debug/libstackable_odbc_sqlite.so`. +Output: `target/release/libstackable_odbc_sqlite.so`. -## Connection string parameters +For Windows, cross-compile with MinGW (`gcc-mingw-w64-x86-64`): -| Parameter | Required | Default | Description | -|-----------|----------|---------|-------------| -| Database | Yes | -- | Path to the SQLite database file (`:memory:` for an in-memory database) | +```bash +rustup target add x86_64-pc-windows-gnu +cargo build --release --target x86_64-pc-windows-gnu +``` -## Testing +Output: `target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll`. -Run all commands from the repository root. +### Installing it + +`packaging/build-archives.sh` turns those binaries into the same release +archives CI publishes, each with an installer inside: ```bash -# Build the driver, create the test database, write the ODBC config -./test/setup.sh +VERSION=0.0.1 ./packaging/build-archives.sh +``` + +On Linux, unpack `stackable-odbc-sqlite-<version>-linux-x64.tar.gz` and run +`sudo ./install.sh`. It copies the library into place and registers it with +unixODBC; check it worked with `odbcinst -q -d`, which should list +`[stackable_odbc_sqlite]`. + +On Windows, unpack the `.zip` and run `install.bat` from an Administrator +Command Prompt, then look for `stackable_odbc_sqlite` on the Drivers tab of +**ODBC Data Sources (64-bit)**. + +The full install, uninstall and DSN reference is in +[`packaging/README.md`](packaging/README.md). -# Connect interactively -export ODBCSYSINI=$(pwd)/test -export ODBCINI=$(pwd)/test/odbc.ini -isql -3 test_sqlite -v +### Then use it + +```python +import pyodbc + +conn = pyodbc.connect("Driver=stackable_odbc_sqlite;Database=/path/to/your.db") +for row in conn.cursor().execute("SELECT name FROM sqlite_master WHERE type = 'table'"): + print(row.name) ``` -Or with a DSN-less connection string: +Or straight from a source checkout, without installing anything at all: ```bash -isql -3 -k "Driver=$(pwd)/target/debug/libstackable_odbc_sqlite.so;Database=$(pwd)/test/test.db" -v +isql -3 -k "Driver=$(pwd)/target/release/libstackable_odbc_sqlite.so;Database=$(pwd)/test/test.db" -v +``` + +## Highlights + +- **The stop button actually stops the query.** Cancelling from your tool calls + SQLite's `sqlite3_interrupt` on the connection, so a runaway query really + stops instead of quietly running to the end while your tool pretends it was + cancelled. The statement reports "operation canceled" and can be re-run. + +- **Real transactions.** Turn autocommit off and the driver opens a transaction + for you, then commits or rolls back when you say so and immediately opens the + next one. Your open result sets survive both, because the driver has already + read every row into memory by the time you commit. + +- **Foreign keys are switched on.** SQLite ships with foreign-key enforcement + *off* for backwards compatibility, which surprises almost everyone. This + driver turns it on for every connection, so a `REFERENCES` clause in your + schema is a rule the database enforces rather than a comment. + +- **Your tool can browse the database.** Tables, views, columns, primary keys, + foreign keys, indexes and row identifiers all show up in the object browser, + read out of SQLite's own `PRAGMA` introspection. So you can click through what + is there instead of guessing table names. + +- **Columns get sensible types even though SQLite has almost none.** SQLite is + dynamically typed: any value can go in any column, and there is no `DATE` or + `BOOLEAN` type at all. The driver reads each column's declared type and its + actual storage class and maps them onto proper ODBC types, including the + three different ways SQLite people store a timestamp (ISO text, Unix seconds, + Julian day numbers). + +- **Nothing is claimed that was not measured.** What a driver reports about + itself is how tools decide which SQL to send, so guessing wrong there breaks + things in confusing ways. The tests here run the actual SQL to check: the list + of `ALTER TABLE` clauses is verified by executing each one, and the list of + reserved words is read out of the linked SQLite library at runtime instead of + being copied from documentation that can drift. + +- **Windows is a real target, not an afterthought.** It gets its own installer, + the DLL is cross-compiled and export-checked on every pull request, and the + test suite can be run through the Windows Driver Manager in a VM, which is far + stricter than unixODBC and tends to fail silently rather than loudly. + +## Connecting + +Connection strings are `Key=Value` pairs joined by `;`. Keys are +case-insensitive. There is exactly one key: + +| Key | Required | Meaning | +|-----|----------|---------| +| `Database` | Yes | Path to the SQLite file, or `:memory:` for a throwaway in-memory database | + +```text +Driver=stackable_odbc_sqlite;Database=/path/to/your.db +``` + +Instead of typing that every time you can save it as a **DSN**, which is just a +named, stored connection, like a browser bookmark. On Linux, add a section to +`~/.odbc.ini`: + +```ini +[SQLite Test] +Driver = stackable_odbc_sqlite +Database = /path/to/your.db ``` -The test database (`test/test.db`) has a `types_test` table with integer, text, -real, boolean, blob, and text-based datetime columns (see -`test/create_test_db.sql`). The full integration suite runs via -`./test/run-tests.sh` (add `--windows` for the VM suite); see -[AGENTS.md](AGENTS.md#testing) for the complete matrix. +On Windows, see [`packaging/README.md`](packaging/README.md). ### Logging +Two environment variables turn on tracing, which is by far the fastest way to +see which ODBC functions your tool actually calls, and in what order: + ```bash -# Log to stderr at debug level +# Levels: trace, debug, info, warn, error ODBC_LOG_LEVEL=debug isql -3 test_sqlite -v -# Log to a file (levels: trace, debug, info, warn, error) +# Or send it to a file instead of stderr ODBC_LOG_LEVEL=debug ODBC_LOG_FILE=/tmp/odbc.log isql -3 test_sqlite -v ``` -This is invaluable for seeing which ODBC functions are called, and in what order. +## What it deliberately does not do + +Every one of these is reported to the application as unsupported rather than +quietly faked, so a tool can react to it instead of trusting a wrong answer. + +- **No catalogs and no schemas.** SQLite has neither, so the driver says so + rather than inventing a fake one-level hierarchy for the sake of looking + familiar. +- **No stored procedures.** SQLite has none, so those lookups return nothing. +- **No query timeout.** You can cancel a running statement from another thread, + but asking for "give up after 30 seconds" is answered with "you have no + timeout" and a warning, instead of a promise that would never be kept. +- **Result sets are read into memory in one go.** Simple, and it is what makes + cursors survive a commit or rollback, but a `SELECT` over a table larger than + your RAM is not going to work. +- **One isolation level.** SQLite gives you serializable transactions, so that + is the only level offered, and asking for a weaker one is refused up front + rather than accepted and silently ignored. +- **No setup dialog.** The driver has no GUI, so the **Add** button in Windows' + ODBC administrator stores whatever it was handed without prompting you for a + database path. Create DSNs with `odbcconf` or by editing `odbc.ini` instead. + +## Testing + +```bash +cargo test # unit and FFI tests; needs no database file and no setup +cargo bench # Criterion fetch-throughput benchmark against :memory: +``` + +`cargo test` drives the real exported C entry points against real handles, so +it catches the marshalling bugs that ordinary Rust tests cannot. + +The integration suite goes one layer further out and runs through real +unixODBC, using Python's `pyodbc` exactly like a normal application would: + +```bash +./test/setup.sh # build the driver, create test/test.db, write the ODBC config +./test/run-tests.sh # run the pyodbc suite, then cargo test +``` + +Both are run on every pull request. `./test/run-tests.sh --windows` additionally +runs the same suite inside a Windows VM; see +[windows/WINDOWS.md](windows/WINDOWS.md) for how to provision one. + +For the architecture, the conventions and the full testing reference, see +[AGENTS.md](AGENTS.md). ## Releasing -See [packaging/README.md](packaging/README.md) for building release archives, -and `release.toml` for the `cargo-release` configuration. +See [packaging/README.md](packaging/README.md) for building the release +archives, and `release.toml` for the `cargo-release` configuration. ## License diff --git a/benches/fetch_sqlite.rs b/benches/fetch_sqlite.rs index 37c8d3d..224e97f 100644 --- a/benches/fetch_sqlite.rs +++ b/benches/fetch_sqlite.rs @@ -5,14 +5,14 @@ //! eager-materialize + per-call clone cost in the SqliteBackend → ColumnValue //! → write_column_value pipeline. //! -//! Two workload shapes (see stackable-odbc-core/benches/fetch_throughput.rs for spec): -//! * Shape A — mixed columns (BENCH_ROWS × BENCH_COLS, 50/40/10 i64/str/decimal) -//! * Shape B — 5 columns × BENCH_WIDE_STR_LEN-char strings (BENCH_WIDE_ROWS rows) +//! Two workload shapes (see stackable-odbc-core/bench/benches/fetch_throughput.rs for spec): +//! * Shape A: mixed columns (BENCH_ROWS × BENCH_COLS, 50/40/10 i64/str/decimal) +//! * Shape B: 5 columns × BENCH_WIDE_STR_LEN-char strings (BENCH_WIDE_ROWS rows) //! //! Three scenarios: -//! * late_binding — SQLFetch + per-cell SQLGetData -//! * bound_columns — SQLBindCol + SQLFetch -//! * repeat_get_data — SQLGetData called BENCH_REPEAT_GET_DATA times per cell +//! * late_binding: SQLFetch + per-cell SQLGetData +//! * bound_columns: SQLBindCol + SQLFetch +//! * repeat_get_data: SQLGetData called BENCH_REPEAT_GET_DATA times per cell //! //! Run: //! cargo bench @@ -130,8 +130,8 @@ unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { /// This used to reach into `ConnectionHandle` for the underlying /// `rusqlite::Connection` to bypass ODBC dispatch; core's `handles` module is /// `pub(crate)` now, and the bypass bought nothing measurable anyway. Every -/// setup here is three statements — `DROP`, `CREATE` and one bulk `INSERT` -/// whose rows are generated by a recursive CTE inside SQLite — so the ODBC +/// setup here is three statements (`DROP`, `CREATE` and one bulk `INSERT` +/// whose rows are generated by a recursive CTE inside SQLite), so the ODBC /// dispatch is paid three times, not once per row. Setup runs outside the /// measured section regardless. /// diff --git a/packaging/README.md b/packaging/README.md index a852503..0686362 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -53,7 +53,7 @@ Requires `unixODBC` (`unixodbc` package) and root privileges for sudo ./install.sh ``` -Verify with `odbcinst -q -d` — the output should include +Verify with `odbcinst -q -d`; the output should include `[stackable_odbc_sqlite]`. To uninstall: @@ -74,7 +74,7 @@ install.bat ``` Verify with the ODBC Data Source Administrator -(`%SystemRoot%\System32\odbcad32.exe`) — the Drivers tab should list +(`%SystemRoot%\System32\odbcad32.exe`); the Drivers tab should list `stackable_odbc_sqlite`. To uninstall: @@ -93,7 +93,7 @@ reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "YourDsnName" /f ## Create a DSN (optional) A DSN stores connection parameters so that users don't need the full -connection string each time. This step is optional — DSN-less connection +connection string each time. This step is optional, since DSN-less connection strings (shown below) work without it. On Windows (`cmd.exe`): @@ -107,8 +107,14 @@ odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=SQLite Test|Database=C:\ > `odbcconf.exe /A '{CONFIGDSN ...}'`. The DSN will appear under the **User DSN** tab in ODBC Data Source -Administrator. Note: the driver has no GUI dialog, so DSNs must be -created via `odbcconf` or the registry, not the "Add" button. +Administrator. + +> **Note:** the driver registers itself as its own `Setup` library and +> implements `ConfigDSNW`, but headlessly: it never displays a dialog. The +> **Add** button therefore does not fail, it silently writes a data source +> from whatever attributes the Driver Manager passed it, which will not +> include `Database`. Create DSNs with `odbcconf` or the registry so that +> every key is set. On Linux, add a section to `/etc/odbc.ini` (or `~/.odbc.ini` for a per-user DSN): diff --git a/src/backend.rs b/src/backend.rs index cadea02..c7253f8 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -47,7 +47,7 @@ pub struct SqliteConnection { /// [`Backend::cancel_token`] cannot fail and cannot block: reaching through /// the `Mutex` would mean either waiting on whatever thread is executing or /// inventing an answer for a poisoned lock. Capturing it once at connect - /// time is also what core's `cancel_token` doc asks for — assemble the + /// time is also what core's `cancel_token` doc asks for: assemble the /// token with the connection in hand, never lazily inside `cancel`. pub(crate) interrupt: Arc<rusqlite::InterruptHandle>, /// True while the application has turned autocommit off. `end_tran` reads @@ -83,7 +83,7 @@ impl SqliteStatement { } /// Create a new SqliteStatement representing a completed statement that - /// produced no result set — DML, DDL, transaction control or a PRAGMA. + /// produced no result set: DML, DDL, transaction control or a PRAGMA. /// /// `affected_rows` is `Some` only for a searched INSERT / UPDATE / DELETE, /// carrying the count reported by rusqlite's `execute()`; everything else @@ -118,7 +118,7 @@ pub enum SqliteError { /// `Backend::Error` is bounded by `From<OdbcError>` so that a defaulted /// trait body can construct an error and still name `Self::Error`. This /// variant is how such an error travels back to core with its SQLSTATE, - /// native error code and causal chain intact — classifying it a second + /// native error code and causal chain intact. Classifying it a second /// time would flatten all three. #[snafu(display("{source}"))] Odbc { source: OdbcError }, @@ -142,7 +142,7 @@ pub enum SqliteError { // // The field is named `cause`, not `source`, because `snafu` special-cases // a field called `source` and requires it to implement `std::error::Error` - // directly — which `Option<rusqlite::Error>` does not. + // directly, which `Option<rusqlite::Error>` does not. #[snafu(display("unable to open database: {message}"))] ConnectionFailed { message: String, @@ -193,7 +193,7 @@ pub enum SqliteError { }, } -/// Operation canceled — `HY008`. +/// Operation canceled (`HY008`). /// /// The SQLSTATE the spec lists for every function that can be stopped by /// `SQLCancel` (`SQLExecDirect`, `SQLExecute`, `SQLFetch`, the catalog @@ -280,7 +280,7 @@ pub(crate) fn map_sqlite_error(e: rusqlite::Error) -> SqliteError { _ => SqliteError::Rusqlite { source: e }, } } - // Errors rusqlite raises itself, without a SQLite result code — so + // Errors rusqlite raises itself, without a SQLite result code, so // there is no extended code to carry, but the error itself is still // worth preserving as the cause. rusqlite::Error::InvalidColumnName(ref name) => { @@ -389,7 +389,7 @@ impl Backend for SqliteBackend { /// because SQLite documents `sqlite3_interrupt` as safe to call from a /// thread other than the one running the query. /// - /// The `Arc` is the requirement core states for an aliasing token — it has + /// The `Arc` is the requirement core states for an aliasing token: it has /// to survive a concurrent `SQLDisconnect`, because core clones the token /// out before doing anything else. `rusqlite`'s `InterruptHandle` already /// satisfies the underlying rule ("it is not safe to call this routine with @@ -407,7 +407,7 @@ impl Backend for SqliteBackend { /// Hand out the connection's interrupt handle. Infallible and lock-free: /// the handle was captured in [`SqliteBackend::connect`], so this only - /// bumps a refcount — see `SqliteConnection::interrupt`. Not an intra-doc + /// bumps a refcount (see `SqliteConnection::interrupt`). Not an intra-doc /// link: that field is `pub(crate)`, and rustdoc rejects a public item /// linking to a private one. fn cancel_token(conn: &SqliteConnection) -> Arc<rusqlite::InterruptHandle> { @@ -419,12 +419,12 @@ impl Backend for SqliteBackend { /// `sqlite3_interrupt` makes the in-flight `sqlite3_step` return /// `SQLITE_INTERRUPT`, which surfaces from /// [`stackable_odbc_core::backend::Backend::exec_direct`] and friends as - /// `HY008` ("operation canceled") via `map_sqlite_error` — the SQLSTATE the + /// `HY008` ("operation canceled") via `map_sqlite_error`, the SQLSTATE the /// spec defines for a statement stopped by `SQLCancel`. /// /// Safe on both of `SQLCancel`'s paths. It never blocks on this - /// connection's own `Mutex`, so the idle path — where core holds the - /// connection's group lock across this call — cannot deadlock; the only + /// connection's own `Mutex`, so the idle path, where core holds the + /// connection's group lock across this call, cannot deadlock; the only /// lock taken is `rusqlite`'s short-lived interrupt lock, which no ODBC /// entry point holds. It is also a no-op rather than an error when nothing /// is running, which is exactly what the spec asks of `SQLCancel` in that @@ -444,7 +444,7 @@ impl Backend for SqliteBackend { // // SQLite defaults this off for backward compatibility. The bundled // library happens to be compiled with `SQLITE_DEFAULT_FOREIGN_KEYS`, - // so it was already on — but that is a property of one dependency's + // so it was already on, but that is a property of one dependency's // build, not of SQLite, and dropping `rusqlite`'s `bundled` feature // for a system library would silently turn referential integrity off // while the driver went on advertising it. @@ -452,7 +452,7 @@ impl Backend for SqliteBackend { // The pragma is per-connection and a no-op inside a transaction; here // there is not one yet. `PRAGMA foreign_keys` is also a no-op rather // than an error on a build compiled with `SQLITE_OMIT_FOREIGN_KEY`, - // which is why `Backend::connect` cannot treat success as proof — + // which is why `Backend::connect` cannot treat success as proof. // `integrity_enhancement_facility_is_actually_enforced` reads the // value back through this function. conn.execute_batch("PRAGMA foreign_keys = ON") @@ -539,7 +539,7 @@ impl Backend for SqliteBackend { /// `SQL_CB_CLOSE`. The value below is a property of this driver's /// architecture, not of SQLite. /// - /// If result sets ever become lazily streamed, revisit both hooks — and + /// If result sets ever become lazily streamed, revisit both hooks, and /// note that `SQL_CB_CLOSE` would then also require a real /// [`stackable_odbc_core::backend::StatementBackend::close_cursor`]. /// @@ -548,7 +548,7 @@ impl Backend for SqliteBackend { CursorBehavior::Preserve } - /// See [`SqliteBackend::cursor_commit_behavior`] — same reasoning, same + /// See [`SqliteBackend::cursor_commit_behavior`]: same reasoning, same /// value. fn cursor_rollback_behavior() -> CursorBehavior { CursorBehavior::Preserve @@ -557,8 +557,8 @@ impl Backend for SqliteBackend { /// `SQL_IC_MIXED`: SQLite stores an unquoted identifier with the case it /// was written in, and matches it case-insensitively. /// - /// `SQL_IC_MIXED` is the spec's value for exactly that pair — "stored in - /// mixed case and case-insensitive" — as opposed to `SQL_IC_UPPER` / + /// `SQL_IC_MIXED` is the spec's value for exactly that pair ("stored in + /// mixed case and case-insensitive"), as opposed to `SQL_IC_UPPER` / /// `SQL_IC_LOWER`, which fold the stored name, and `SQL_IC_SENSITIVE`, /// which would make `SELECT * FROM T` and `SELECT * FROM t` name different /// tables. They do not. @@ -568,7 +568,7 @@ impl Backend for SqliteBackend { /// for "case-insensitive for some characters". /// /// Distinct from [`SqliteBackend::quoted_identifier_case`], which describes - /// *quoted* identifiers — and which answers the same here, for the reason + /// *quoted* identifiers, and which answers the same here, for the reason /// given there. /// /// <https://sqlite.org/lang_keywords.html> @@ -584,7 +584,7 @@ impl Backend for SqliteBackend { /// *delimiter* here, not a case-sensitivity switch: they let a keyword or a /// name with punctuation be used as an identifier, and nothing more. A /// table created as `"MixedCase"` is still found by `"mixedcase"`, and the - /// catalog stores the name with the case it was written in — which is + /// catalog stores the name with the case it was written in, which is /// precisely `SQL_IC_MIXED`. /// /// `quoted_identifiers_are_not_case_sensitive` probes this against the @@ -601,9 +601,9 @@ impl Backend for SqliteBackend { /// NULL for every row, and a `catalog = "%"` enumeration returns an empty /// result set. /// - /// Core derives the whole catalog group from this — `SQL_CATALOG_NAME`, + /// Core derives the whole catalog group from this (`SQL_CATALOG_NAME`, /// `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR`, - /// `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` — so this driver answers + /// `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE`), so this driver answers /// none of them itself. Before the hook existed it answered three and let /// the other two inherit defaults that named a catalog, telling an /// application catalogs do not exist and giving their name in the same @@ -648,9 +648,9 @@ impl Backend for SqliteBackend { /// The only level reachable from this driver. /// /// READ COMMITTED and REPEATABLE READ are not SQLite concepts. READ - /// UNCOMMITTED needs shared-cache mode — "the only way that one database + /// UNCOMMITTED needs shared-cache mode ("the only way that one database /// connection can see uncommitted changes on a different database - /// connection" — and [`SqliteBackend::connect`] opens with a plain + /// connection"), and [`SqliteBackend::connect`] opens with a plain /// `rusqlite::Connection::open`, so it is unreachable. /// /// Returning a single level also means core's default @@ -662,7 +662,7 @@ impl Backend for SqliteBackend { } /// `SQL_TC_DML`: SQLite runs DML inside a transaction, and a DDL statement - /// inside one causes neither a commit nor an error — SQLite's DDL is + /// inside one causes neither a commit nor an error. SQLite's DDL is /// transactional, so `CREATE TABLE` simply participates. /// /// `SQL_TC_ALL` would be the stronger claim and is tempting for that @@ -678,9 +678,9 @@ impl Backend for SqliteBackend { /// level and then reporting no transaction support is the /// self-contradiction that pairing exists to catch. /// - /// `SQL_TC_DML` is a small fixed constant, so the narrowing `as u16` — the - /// `SQL_TC_*` constants are typed `u32` for bitmask use, while the info - /// type is `SQLUSMALLINT` — cannot lose information. + /// `SQL_TC_DML` is a small fixed constant, so the narrowing `as u16` + /// cannot lose information. (The `SQL_TC_*` constants are typed `u32` for + /// bitmask use, while the info type is `SQLUSMALLINT`.) fn txn_capable(_conn: &SqliteConnection) -> u16 { SQL_TC_DML as u16 } @@ -691,7 +691,7 @@ impl Backend for SqliteBackend { /// /// The spec asks about the *driver*, not about one connection: "`"Y"` if /// the driver supports more than one active transaction at the same time". - /// Nothing here serialises across connections — `SqliteBackend::connect` + /// Nothing here serialises across connections: `SqliteBackend::connect` /// opens a fresh handle per call and shares no state between them. What /// SQLite does when those transactions contend for the same file is a /// locking question (`SQLITE_BUSY`), not a question of how many can be @@ -700,9 +700,9 @@ impl Backend for SqliteBackend { true } - /// `true`: SQLite implements the whole Integrity Enhancement Facility — - /// `PRIMARY KEY`, `UNIQUE`, `NOT NULL`, `CHECK`, `DEFAULT` and `FOREIGN - /// KEY` with referential actions — and this build enforces all of it. + /// `true`: SQLite implements the whole Integrity Enhancement Facility + /// (`PRIMARY KEY`, `UNIQUE`, `NOT NULL`, `CHECK`, `DEFAULT` and `FOREIGN + /// KEY` with referential actions), and this build enforces all of it. /// /// Referential integrity in particular is enforced by construction, not by /// chance: [`SqliteBackend::connect`] issues `PRAGMA foreign_keys = ON`, @@ -730,7 +730,7 @@ impl Backend for SqliteBackend { SQL_GB_NO_RELATION } - /// `SQL_NC_LOW`: SQLite sorts NULLs at the low end — first ascending, last + /// `SQL_NC_LOW`: SQLite sorts NULLs at the low end, first ascending, last /// descending. fn null_collation(_conn: &SqliteConnection) -> u16 { SQL_NC_LOW @@ -761,7 +761,7 @@ impl Backend for SqliteBackend { /// driver will always return the SQL_GB_GROUP_BY_EQUALS_SELECT option as /// supported", "will always return SQL_CN_ANY", and "will return /// SQL_NNC_NON_NULL". This driver matches the last two and cannot match the - /// first — SQLite's `GROUP BY` is deliberately unrelated to the select list + /// first: SQLite's `GROUP BY` is deliberately unrelated to the select list /// (see [`SqliteBackend::group_by`]), which is a permissive extension, not /// entry-level behaviour. /// @@ -797,18 +797,18 @@ impl Backend for SqliteBackend { true } - /// `SQL_CB_NULL`: concatenating a NULL yields NULL — `'a' || NULL` is + /// `SQL_CB_NULL`: concatenating a NULL yields NULL. `'a' || NULL` is /// NULL, not `'a'`. fn concat_null_behavior(_conn: &SqliteConnection) -> u16 { SQL_CB_NULL } - /// See `info::SQLITE_UNION` — both `UNION` and `UNION ALL`. + /// See `info::SQLITE_UNION`: both `UNION` and `UNION ALL`. fn union_support(_conn: &SqliteConnection) -> u32 { info::SQLITE_UNION } - /// See `info::SQLITE_CONVERT_FUNCTIONS` — `CAST` only. + /// See `info::SQLITE_CONVERT_FUNCTIONS`: `CAST` only. fn convert_functions(_conn: &SqliteConnection) -> u32 { info::SQLITE_CONVERT_FUNCTIONS } @@ -837,7 +837,7 @@ impl Backend for SqliteBackend { /// The counterpart of [`SqliteBackend::accessible_tables`], and the /// opposite answer for a different reason. That one is `true` because every /// table SQLTables returns is reachable; this is `false` because - /// `SQLProcedures` returns nothing to be reachable in the first place — + /// `SQLProcedures` returns nothing to be reachable in the first place: /// this driver leaves `Backend::procedures` defaulted to no rows, and /// reports `SQL_PROCEDURES = "N"` through core. fn accessible_procedures(_conn: &SqliteConnection) -> bool { @@ -848,7 +848,7 @@ impl Backend for SqliteBackend { /// /// This describes the driver's own behaviour, not the file. A database on /// read-only media, or one whose file permissions deny writes, still - /// reports `false` here and fails the write itself — which is what the + /// reports `false` here and fails the write itself, which is what the /// spec's "data source is set to READ ONLY mode" means. fn data_source_read_only(_conn: &SqliteConnection) -> bool { false @@ -864,14 +864,14 @@ impl Backend for SqliteBackend { Cow::Borrowed(info::sqlite_keywords()) } - /// `"$"` — the one character beyond `a`–`z`, `A`–`Z`, `0`–`9` and `_` that + /// `"$"`, the one character beyond `a`–`z`, `A`–`Z`, `0`–`9` and `_` that /// SQLite accepts in an undelimited identifier. /// /// SQLite's tokenizer treats `$` as an identifier character, so /// `CREATE TABLE a$b (...)` parses and the name round-trips through /// `sqlite_master` unchanged. An application reads this info type to decide - /// when it must quote, so the previous `""` — core's old default, not a - /// claim this driver ever made — told it to quote a name that needs no + /// when it must quote, so the previous `""` (core's old default, not a + /// claim this driver ever made) told it to quote a name that needs no /// quoting. /// /// Every candidate is executed against the bundled library in @@ -927,8 +927,8 @@ impl Backend for SqliteBackend { /// /// The spec permits appending the data source's own version string after /// the fixed-width prefix, which keeps the familiar `3.53.2` visible to - /// anyone reading the value by eye. Read from `rusqlite::version()` — the - /// library actually linked — rather than written down, for the reason + /// anyone reading the value by eye. Read from `rusqlite::version()`, the + /// library actually linked, rather than written down, for the reason /// AGENTS.md gives about the system `sqlite3` binary being a different /// version. fn dbms_version(_conn: &SqliteConnection) -> Cow<'static, str> { @@ -1012,7 +1012,7 @@ impl Backend for SqliteBackend { metadata::tables(conn, query) } - /// `TABLE` and `VIEW` — the two values `metadata::tables` can put in + /// `TABLE` and `VIEW`, the two values `metadata::tables` can put in /// `TABLE_TYPE`. See `metadata::table_types`. fn table_types(_conn: &SqliteConnection) -> Vec<Cow<'static, str>> { metadata::table_types() diff --git a/src/backend/execute.rs b/src/backend/execute.rs index 7a01514..cb516e7 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -21,7 +21,7 @@ use crate::type_conversion::{ /// Nullability and the originating table come from /// `sqlite3_table_column_metadata`, which SQLite answers only for a column /// that is a plain reference to a stored table column. For a computed -/// column — an expression, a literal, an aggregate — it reports nothing, and +/// column (an expression, a literal, an aggregate) it reports nothing, and /// that is precisely `SQL_NULLABLE_UNKNOWN`: the driver cannot determine /// whether the column admits NULL, and the spec's third value says exactly /// that instead of guessing one of the other two. Guessing is not harmless in @@ -31,7 +31,7 @@ use crate::type_conversion::{ /// The catalog and schema stay empty even though SQLite names a database for /// the column. This driver reports `supports_catalogs() == false` and /// `supports_schemas() == false`, so naming either here would contradict what -/// it tells applications everywhere else — `metadata::tables` reports +/// it tells applications everywhere else: `metadata::tables` reports /// `TABLE_CAT` and `TABLE_SCHEM` as NULL for every row. fn describe_column( stmt: &rusqlite::Statement<'_>, @@ -55,7 +55,7 @@ fn describe_column( // ("VARCHAR(50)") matches no `SQLGetTypeInfo` row. // `sqlite_bare_type_name` returns the bare name that does (see its doc // comment in `backend/info.rs`); the declared length is not lost, only - // moved out of the name — it is still carried as the precision above. + // moved out of the name. It is still carried as the precision above. .with_type_name(sqlite_bare_type_name(sql_type)); // `Ok(None)` is a computed column and `Err` is SQLite failing to resolve a @@ -88,7 +88,7 @@ fn describe_column( /// skipped. The empty string when there is none. /// /// SQLite accepts both comment forms before the opening keyword, and an -/// unterminated block comment is legal — it swallows the rest of the text — so +/// unterminated block comment is legal (it swallows the rest of the text), so /// both are handled rather than assumed away. fn leading_keyword(sql: &str) -> &str { let mut rest = sql.trim_start(); @@ -113,20 +113,20 @@ fn leading_keyword(sql: &str) -> &str { &rest[..end] } -/// Whether `sql` is a searched INSERT, UPDATE or DELETE — the only statements +/// Whether `sql` is a searched INSERT, UPDATE or DELETE, the only statements /// that have an affected-row count for `SQLRowCount` to report. /// /// Core reads [`StatementBackend::row_count`] as three distinct answers: /// `Some(n)` is "the backend counted", `Some(SQL_NO_TOTAL)` is "cannot /// determine", and `None` is "not applicable to this statement". It turns a /// zero-column statement answering `Some(0)` into `SQL_NO_DATA`, per -/// `SQLExecDirect`'s Comments — "if SQLExecDirect executes a searched update, +/// `SQLExecDirect`'s Comments: "if SQLExecDirect executes a searched update, /// insert, or delete statement that doesn't affect any rows at the data /// source, the call to SQLExecDirect returns SQL_NO_DATA". A `CREATE TABLE` /// answering `Some(0)` therefore looked to an application exactly like a /// searched DELETE that matched nothing. /// -/// SQLite exposes no predicate for this — `sqlite3_stmt_readonly` is false for +/// SQLite exposes no predicate for this. `sqlite3_stmt_readonly` is false for /// DDL too, and `sqlite3_changes()` is worse than useless here, since it holds /// the count from the *most recently completed* INSERT, UPDATE or DELETE and /// so reports a stale count after a `CREATE TABLE`. The leading keyword is what @@ -357,13 +357,13 @@ impl StatementBackend for SqliteStatement { /// `i64` because `SQLRowCount` writes through a signed `SQLLEN *`. /// - /// Three answers, and core distinguishes all three — see + /// Three answers, and core distinguishes all three. See /// [`is_searched_dml`] for what it does with them: /// /// - **`Some(n)`** for a searched INSERT / UPDATE / DELETE, and for a /// result set, whose materialised size this driver genuinely knows. /// - **`Some(SQL_NO_TOTAL)`** for a count that does not fit `i64`, the - /// spec's "the driver cannot determine the row count" — which is what a + /// spec's "the driver cannot determine the row count", which is what a /// value this type cannot name actually means. Unreachable in practice: /// rows are materialised in memory, so `i64::MAX` of them cannot be held. /// - **`None`** for a statement with no affected-row count at all: DDL, @@ -457,7 +457,7 @@ mod tests { } /// A zero-column statement that reports `Some(0)` is what core turns into - /// `SQL_NO_DATA`, so DDL must report `None` — "no affected-row count" — + /// `SQL_NO_DATA`, so DDL must report `None` ("no affected-row count") /// rather than "counted zero". With `Some(0)` here every `CREATE TABLE` /// this driver ran came back as `SQL_NO_DATA`, and the whole FFI test /// suite's table setup failed. @@ -500,7 +500,7 @@ mod tests { } /// A searched DELETE matching nothing is the case the spec actually - /// reserves `SQL_NO_DATA` for, and it must keep reporting `Some(0)` — the + /// reserves `SQL_NO_DATA` for, and it must keep reporting `Some(0)`. The /// fix above must not suppress it along with the DDL. #[test] fn searched_dml_matching_nothing_still_counts_zero() { diff --git a/src/backend/info.rs b/src/backend/info.rs index ea1d09a..c8fed40 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -124,12 +124,12 @@ static SUPPORTED_FUNCTIONS: &[FunctionId] = &[ // // A `LazyLock` rather than a plain `static`: `TypeInfoRow`'s string fields are // `Cow<'static, str>` so a backend can compute them, and converting a `&'static -// str` literal through `Into` is not a const operation — `TypeInfoRow::new` and -// the three string builders are therefore not `const fn`. The table is fixed at +// str` literal through `Into` is not a const operation, so `TypeInfoRow::new` +// and the three string builders are not `const fn`. The table is fixed at // compile time, so it is built once and borrowed for the life of the process. static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::LazyLock::new(|| { vec![ - // WVARCHAR — sqlite_type_to_sql_data_type maps VARCHAR/CHAR/CHARACTER/ + // WVARCHAR: sqlite_type_to_sql_data_type maps VARCHAR/CHAR/CHARACTER/ // NCHAR/NVARCHAR/VARYING CHARACTER/NATIVE CHARACTER/TEXT/CLOB here, and // it is the CHAR/CLOB/TEXT-affinity fallback too. This is the row that // actually satisfies the invariant for every text-affinity declared @@ -144,7 +144,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_literal_affixes(Some("'"), Some("'")) .with_create_params(Some("max length")) .with_case_sensitive(true), - // WCHAR — Unicode counterpart to the CHAR row further down this list, + // WCHAR: Unicode counterpart to the CHAR row further down this list, // included for symmetry per the Windows DM checklist even though // sqlite_type_to_sql_data_type itself never produces EXT_W_CHAR (declared // CHAR(n) collapses into the WVARCHAR affinity above, matching real @@ -158,13 +158,13 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_literal_affixes(Some("'"), Some("'")) .with_create_params(Some("length")) .with_case_sensitive(true), - // BIT — sqlite_type_to_sql_data_type maps BOOLEAN/BOOL here. + // BIT: sqlite_type_to_sql_data_type maps BOOLEAN/BOOL here. TypeInfoRow::new("BIT", SqlDataType::EXT_BIT).with_column_size(catalog_column_size( SqlDataType::EXT_BIT, MaxPrecision(0), MaxScale(0), )), - // TINYINT — sqlite_type_to_sql_data_type maps TINYINT here. + // TINYINT: sqlite_type_to_sql_data_type maps TINYINT here. TypeInfoRow::new("TINYINT", SqlDataType::EXT_TINY_INT) .with_column_size(catalog_column_size( SqlDataType::EXT_TINY_INT, @@ -175,7 +175,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_auto_unique_value(Some(false)) .with_scale_range(Some(0), Some(0)) .with_num_prec_radix(Some(10)), - // BIGINT — sqlite_type_to_sql_data_type maps INTEGER/INT/BIGINT/INT8 here + // BIGINT: sqlite_type_to_sql_data_type maps INTEGER/INT/BIGINT/INT8 here // (and the "INT"-substring affinity fallback), since SQLite integers are // always 64-bit storage. This is the row an INTEGER column's reported // type (SQL_BIGINT) actually resolves to. @@ -197,7 +197,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy )) .with_literal_affixes(Some("X'"), Some("'")) .with_create_params(Some("max length")), - // SQL_CHAR (1) — ANSI alias. See the SQL_VARCHAR comment further down + // SQL_CHAR (1): ANSI alias. See the SQL_VARCHAR comment further down // this list; same rationale for why this is a distinct row from the // WCHAR row above. TypeInfoRow::new("CHAR", SqlDataType::CHAR) @@ -209,7 +209,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_literal_affixes(Some("'"), Some("'")) .with_create_params(Some("length")) .with_case_sensitive(true), - // DECIMAL — sqlite_type_to_sql_data_type maps DECIMAL/NUMERIC here, and + // DECIMAL: sqlite_type_to_sql_data_type maps DECIMAL/NUMERIC here, and // it is also the NUMERIC-affinity fallback for any declared type that // SQLite's own affinity rules do not otherwise classify. TypeInfoRow::new("DECIMAL", SqlDataType::DECIMAL) @@ -233,7 +233,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_auto_unique_value(Some(false)) .with_scale_range(Some(0), Some(0)) .with_num_prec_radix(Some(10)), - // SMALLINT — sqlite_type_to_sql_data_type maps SMALLINT/INT2 here. + // SMALLINT: sqlite_type_to_sql_data_type maps SMALLINT/INT2 here. TypeInfoRow::new("SMALLINT", SqlDataType::SMALLINT) .with_column_size(catalog_column_size( SqlDataType::SMALLINT, @@ -252,7 +252,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy )) .with_unsigned(Some(false)) .with_num_prec_radix(Some(2)), - // TEXT — column_size matches VARCHAR_DEFAULT_COLUMN_SIZE (255), the + // TEXT: column_size matches VARCHAR_DEFAULT_COLUMN_SIZE (255), the // same default `default_precision_for_type` reports for both VARCHAR and // EXT_W_VARCHAR (see type_conversion.rs). This row and the VARCHAR row // immediately below both describe SQLite's single, unbounded TEXT @@ -269,7 +269,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_literal_affixes(Some("'"), Some("'")) .with_create_params(Some("max length")) .with_case_sensitive(true), - // SQL_VARCHAR (12) — ANSI alias needed for Windows DM / pyodbc type + // SQL_VARCHAR (12): ANSI alias needed for Windows DM / pyodbc type // conversion (AGENTS.md "Windows Driver Manager compatibility // checklist"). sqlite_type_to_sql_data_type never actually returns this // ANSI code (only EXT_W_VARCHAR, see the WVARCHAR row above); this row @@ -288,7 +288,7 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_literal_affixes(Some("'"), Some("'")) .with_create_params(Some("max length")) .with_case_sensitive(true), - // DATE — sqlite_type_to_sql_data_type maps DATE here. SQLite has no DATE + // DATE: sqlite_type_to_sql_data_type maps DATE here. SQLite has no DATE // literal syntax; a date value is just a quoted ISO-8601 string, hence // the plain quote prefix/suffix (matching the TEXT row's convention) // rather than a typed `DATE '...'` literal. @@ -302,14 +302,15 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy // 'YYYY-MM-DD' .with_literal_affixes(Some("'"), Some("'")) .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_DATE)), - // TIME — sqlite_type_to_sql_data_type maps TIME here. SQLite stores time - // values as plain "HH:MM:SS" text with no fractional-seconds field (see - // column_value_to_rusqlite), so scale is fixed at 0. + // TIME: sqlite_type_to_sql_data_type maps TIME here. SQLite stores + // time values as text, and MAX_FRACTIONAL_SECONDS_PRECISION (3) is the + // fraction its own date/time functions render (see + // column_value_to_rusqlite), so that is the maximum scale reported. // DATA_TYPE=92 (SQL_TYPE_TIME), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=2 (SQL_CODE_TIME) TypeInfoRow::new("TIME", SqlDataType::TIME) - // 'HH:MM:SS': SQLite has no fractional-seconds capability to report - // as a maximum (MAX_FRACTIONAL_SECONDS_PRECISION = 0), so this is - // the plain (scale-0) form of the TIME formula. + // 'HH:MM:SS.fff': the spec's TIME formula at + // MAX_FRACTIONAL_SECONDS_PRECISION, which budgets the separator + // and the three fractional digits alongside the eight fixed ones. .with_column_size(catalog_column_size( SqlDataType::TIME, MaxPrecision(0), @@ -318,17 +319,17 @@ static SQLITE_TYPE_INFO: std::sync::LazyLock<Vec<TypeInfoRow>> = std::sync::Lazy .with_literal_affixes(Some("'"), Some("'")) .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIME)), - // TIMESTAMP — sqlite_type_to_sql_data_type maps DATETIME/TIMESTAMP here. - // column_size intentionally excludes a fractional-seconds allowance: it - // is computed via catalog_column_size at MAX_FRACTIONAL_SECONDS_PRECISION - // (0), the same constant sqlite_declared_type_precision uses as the - // fallback for an undeclared TIMESTAMP column (see the consistency test - // below), so minimum/maximum scale are reported as fixed at 0 rather - // than claiming precision the column size does not budget for. + // TIMESTAMP: sqlite_type_to_sql_data_type maps DATETIME/TIMESTAMP + // here. column_size is computed via catalog_column_size at + // MAX_FRACTIONAL_SECONDS_PRECISION, the same constant + // sqlite_declared_type_precision uses as the fallback for an + // undeclared TIMESTAMP column (see the consistency test below), so the + // reported maximum scale and the budgeted column size cannot + // disagree. // DATA_TYPE=93 (SQL_TYPE_TIMESTAMP), SQL_DATA_TYPE=9 (SQL_DATETIME), SQL_DATETIME_SUB=3 (SQL_CODE_TIMESTAMP) TypeInfoRow::new("TIMESTAMP", SqlDataType::TIMESTAMP) - // 'YYYY-MM-DD HH:MM:SS': same no-fractional-capability rationale - // as the TIME row above. + // 'YYYY-MM-DD HH:MM:SS.fff': same rationale as the TIME row + // above. .with_column_size(catalog_column_size( SqlDataType::TIMESTAMP, MaxPrecision(0), @@ -383,7 +384,7 @@ fn sqlite_get_info( // `supports_catalogs`/`supports_schemas` are per-connection hooks, so // these arms only apply once a connection exists. Pre-connect the // question falls through to core, which answers its generic identifier - // length -- the same shape it reports for every other `SQL_MAX_*_LEN` + // length, the same shape it reports for every other `SQL_MAX_*_LEN` // before a data source is open. InfoType::MaxCatalogNameLen if conn.is_some_and(|c| !SqliteBackend::supports_catalogs(c)) => @@ -405,8 +406,8 @@ fn sqlite_get_info( // level is unreachable. // // This previously advertised all four levels. Nothing applies the - // value an application sets -- `SQL_ATTR_TXN_ISOLATION` is stored on - // the connection and read back, never pushed to SQLite -- so an + // value an application sets (`SQL_ATTR_TXN_ISOLATION` is stored on + // the connection and read back, never pushed to SQLite), so an // application that asked for REPEATABLE READ was told it had it while // running serializable. // @@ -415,7 +416,7 @@ fn sqlite_get_info( return Ok(InfoValue::U32(SQL_TXN_SERIALIZABLE)); } // SQL_GETDATA_EXTENSIONS is deliberately not answered here. It states - // what core's own fetch path supports -- `sql_get_data` checks neither + // what core's own fetch path supports: `sql_get_data` checks neither // column order nor binding state, and `sql_set_stmt_attr_w` substitutes // 1 back for any SQL_ATTR_ROW_ARRAY_SIZE, so no block cursor can exist // for SQL_GD_BLOCK to describe. None of that is a fact about SQLite, @@ -447,7 +448,7 @@ pub(super) fn get_info( /// `sqlite3_limit` (`rusqlite::Connection::limit`, a safe wrapper). /// /// The spec allows `0` for "no specified limit or the limit is unknown", and -/// core answers `0` for exactly that reason — it has no way to know. This +/// core answers `0` for exactly that reason, having no way to know. This /// driver does: these are real, enforced limits, and an application reads them /// to decide whether to chunk a wide `SELECT` or a long `IN` list. `0` tells it /// there is nothing to chunk around. @@ -458,7 +459,7 @@ pub(super) fn get_info( /// /// Returns `None` for every other info type, leaving `sqlite_get_info` to /// answer. `get_info_pre_connect` has no connection and so keeps reporting -/// `0` — with no connection the limit genuinely is unknown, which is what `0` +/// `0`: with no connection the limit genuinely is unknown, which is what `0` /// means. fn connection_limit( conn: &SqliteConnection, @@ -513,17 +514,17 @@ pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, Sql sqlite_get_info(None, info_type) } -/// `SQL_AGGREGATE_FUNCTIONS` — SQLite has every ODBC aggregate, and accepts +/// `SQL_AGGREGATE_FUNCTIONS`: SQLite has every ODBC aggregate, and accepts /// both `DISTINCT` and `ALL` as set quantifiers. /// <https://sqlite.org/lang_aggfunc.html> pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = SQL_AF_AVG | SQL_AF_COUNT | SQL_AF_MAX | SQL_AF_MIN | SQL_AF_SUM | SQL_AF_DISTINCT | SQL_AF_ALL; -/// `SQL_ALTER_TABLE` (86) — the `ALTER TABLE` clauses SQLite accepts, of those +/// `SQL_ALTER_TABLE` (86): the `ALTER TABLE` clauses SQLite accepts, of those /// the ODBC bitmap can express. /// /// Every bit here was established by executing the clause against the bundled -/// library (3.53.2), not read off the documentation — +/// library (3.53.2), not read off the documentation. /// `alter_table_capabilities_are_each_live_probed` is that probe, and it /// checks the unclaimed bits too. That matters: `ADD CONSTRAINT` and /// `DROP CONSTRAINT` are recent additions, rejected by 3.51.3 and accepted by @@ -535,7 +536,7 @@ pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = /// - `ADD COLUMN`, with `DEFAULT` and `COLLATE`. /// - `ADD CONSTRAINT <name> CHECK (...)`, which rewrites the stored schema to /// carry a genuine table constraint. Note the ODBC bit is all-or-nothing -/// while SQLite accepts only `CHECK` here — `UNIQUE`, `PRIMARY KEY` and +/// while SQLite accepts only `CHECK` here; `UNIQUE`, `PRIMARY KEY` and /// `FOREIGN KEY` are still syntax errors. /// - `SQL_AT_CONSTRAINT_NAME_DEFINITION`, since that `CONSTRAINT <name>` clause /// is exactly what the bit describes. @@ -549,7 +550,7 @@ pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = /// Supported by SQLite but *unrepresentable*, so absent by necessity rather /// than because SQLite lacks them: unqualified `DROP COLUMN` (3.35.0+) and /// unqualified `DROP CONSTRAINT`, for which the ODBC 3.x bitmap offers only -/// `CASCADE` and `RESTRICT` variants — and SQLite rejects both keywords, so +/// `CASCADE` and `RESTRICT` variants, and SQLite rejects both keywords, so /// claiming either would advertise a syntax an application would send and have /// refused. `sql.h` does carry ODBC 2.0-era `SQL_AT_ADD_COLUMN` and /// `SQL_AT_DROP_COLUMN` bits for the unqualified forms, but the ODBC 3.x @@ -573,10 +574,10 @@ pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = /// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> /// SQLite: <https://www.sqlite.org/lang_altertable.html> -/// `SQL_SUBQUERIES` (95) — the subquery forms SQLite accepts. +/// `SQL_SUBQUERIES` (95): the subquery forms SQLite accepts. /// /// `SQL_SQ_QUANTIFIED` is deliberately absent. It covers `< ALL` / `< ANY` / -/// `< SOME`, which SQLite does not parse — the same finding +/// `< SOME`, which SQLite does not parse. That is the same finding /// `sql92_predicates_excludes_quantified_comparison_and_match` records for /// `SQL_SP_QUANTIFIED_COMPARISON`. Core's default claimed it, so this driver /// denied quantified comparison in one info type and asserted it in another. @@ -584,10 +585,10 @@ pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = pub(crate) const SQLITE_SUBQUERIES: u32 = SQL_SQ_COMPARISON | SQL_SQ_EXISTS | SQL_SQ_IN | SQL_SQ_CORRELATED_SUBQUERIES; -/// `SQL_UNION` (96) — SQLite has both `UNION` and `UNION ALL`. +/// `SQL_UNION` (96): SQLite has both `UNION` and `UNION ALL`. pub(crate) const SQLITE_UNION: u32 = SQL_U_UNION | SQL_U_UNION_ALL; -/// `SQL_SPECIAL_CHARACTERS` (94) — the characters beyond `a`–`z`, `A`–`Z`, +/// `SQL_SPECIAL_CHARACTERS` (94): the characters beyond `a`–`z`, `A`–`Z`, /// `0`–`9` and `_` that may appear in an undelimited SQLite identifier. /// /// Just `$`. SQLite's tokenizer classifies it as an identifier character, so a @@ -609,11 +610,11 @@ pub(crate) const SQLITE_SPECIAL_CHARACTERS: &str = "$"; #[cfg(test)] pub(crate) const SPECIAL_CHARACTER_CANDIDATES: &str = "$#@!%^&*-+=./:?~`|\\'\"<>(){}[],;"; -/// `SQL_CONVERT_FUNCTIONS` (48) — SQLite's `CAST(x AS type)`. It has no +/// `SQL_CONVERT_FUNCTIONS` (48): SQLite's `CAST(x AS type)`. It has no /// ODBC `CONVERT` scalar function, so only the `CAST` bit is claimed. pub(crate) const SQLITE_CONVERT_FUNCTIONS: u32 = SQL_FN_CVT_CAST; -/// `SQL_OUTER_JOIN_CAPABILITIES` (115) — every outer-join form SQLite +/// `SQL_OUTER_JOIN_CAPABILITIES` (115): every outer-join form SQLite /// implements, and every relaxation of the `ON` clause the bitmap asks about. /// /// Each bit is proved by executing the join it describes against the bundled @@ -637,7 +638,7 @@ pub(crate) const SQLITE_ALTER_TABLE: u32 = SQL_AT_ADD_COLUMN_SINGLE /// `SQL_SQL92_PREDICATES`. /// /// Deliberately absent: quantified comparison (`< ALL` / `< ANY` / `< SOME` -/// all fail to prepare -- SQLite's `ALL`/`ANY` are set quantifiers on +/// all fail to prepare, SQLite's `ALL`/`ANY` being set quantifiers on /// compound selects, not comparison quantifiers); the four `MATCH` variants /// (SQLite's `MATCH` is an FTS extension hook, not the SQL-92 row-matching /// predicate); `OVERLAPS`; and `UNIQUE`. @@ -666,12 +667,12 @@ pub(crate) const SQLITE_SQL92_JOIN_OPERATORS: u32 = SQL_SRJO_CROSS_JOIN | SQL_SRJO_NATURAL_JOIN | SQL_SRJO_RIGHT_OUTER_JOIN; -/// `SQL_SQL92_VALUE_EXPRESSIONS` — all four present. +/// `SQL_SQL92_VALUE_EXPRESSIONS`: all four present. /// <https://sqlite.org/lang_expr.html> pub(crate) const SQLITE_SQL92_VALUE_EXPRESSIONS: u32 = SQL_SVE_CASE | SQL_SVE_CAST | SQL_SVE_COALESCE | SQL_SVE_NULLIF; -/// `SQL_NUMERIC_FUNCTIONS` — only three. +/// `SQL_NUMERIC_FUNCTIONS`: only three. /// /// `rusqlite`'s `bundled` feature does **not** define /// `SQLITE_ENABLE_MATH_FUNCTIONS`, so the entire trig/log/power/sqrt set is @@ -683,19 +684,19 @@ pub(crate) const SQLITE_SQL92_VALUE_EXPRESSIONS: u32 = /// assumption that "math functions are off" implies no `sign()`. /// /// Deliberately absent despite near-misses: `MOD` (`%` is an operator, not a -/// function, and is integer-only -- `7.5 % 2` yields `1`) and `RAND` +/// function, and is integer-only, so `7.5 % 2` yields `1`) and `RAND` /// (`random()` returns a signed 64-bit integer, not ODBC's float in `[0,1)`, /// and takes no seed). /// <https://sqlite.org/lang_corefunc.html> pub(crate) const SQLITE_NUMERIC_FUNCTIONS: u32 = SQL_FN_NUM_ABS | SQL_FN_NUM_SIGN | SQL_FN_NUM_ROUND; -/// `SQL_STRING_FUNCTIONS` — SQLite equivalents, several under other names: +/// `SQL_STRING_FUNCTIONS`: SQLite equivalents, several under other names: /// `LCASE` is `lower()`, `UCASE` is `upper()`, `SUBSTRING` is `substr()`, /// `ASCII` is `unicode()`, `CHAR` is `char()`. /// /// `SOUNDEX` is claimed because this build enables `SQLITE_SOUNDEX`, which is -/// **not** the SQLite default -- verified by probe (`soundex('Robert')` gives +/// **not** the SQLite default, verified by probe (`soundex('Robert')` gives /// `R163`). A future `rusqlite` bump could silently drop it, which is why /// `tests::live_sqlite_supports_sign_soundex_and_octet_length` opens a real /// in-memory connection and calls it (along with `sign()` and @@ -704,7 +705,7 @@ pub(crate) const SQLITE_NUMERIC_FUNCTIONS: u32 = /// definition. /// /// Deliberately absent: `LOCATE` and `LOCATE_2`, because `instr(haystack, -/// needle)` reverses ODBC's `LOCATE(needle, haystack)` -- claiming it would +/// needle)` reverses ODBC's `LOCATE(needle, haystack)`; claiming it would /// produce silently wrong answers rather than a clean failure. Also absent: /// `LEFT`/`RIGHT`/`SPACE`/`INSERT`/`REPEAT`/`DIFFERENCE` (no such function) /// and the `CHAR_LENGTH`/`CHARACTER_LENGTH`/`BIT_LENGTH`/`POSITION` family, @@ -723,7 +724,7 @@ pub(crate) const SQLITE_STRING_FUNCTIONS: u32 = SQL_FN_STR_CONCAT | SQL_FN_STR_SOUNDEX | SQL_FN_STR_OCTET_LENGTH; -/// `SQL_SYSTEM_FUNCTIONS` — only `IFNULL`, which SQLite spells the same way. +/// `SQL_SYSTEM_FUNCTIONS`: only `IFNULL`, which SQLite spells the same way. /// /// SQLite has no user concept, so no `USERNAME`; and no scalar /// database-name function, only the `pragma_database_list` table-valued @@ -731,14 +732,14 @@ pub(crate) const SQLITE_STRING_FUNCTIONS: u32 = SQL_FN_STR_CONCAT /// <https://sqlite.org/lang_corefunc.html> pub(crate) const SQLITE_SYSTEM_FUNCTIONS: u32 = SQL_FN_SYS_IFNULL; -/// `SQL_TIMEDATE_FUNCTIONS` — only the current-date/time family. +/// `SQL_TIMEDATE_FUNCTIONS`: only the current-date/time family. /// /// `date()`, `time()` and `datetime()` take no arguments and return the /// current value, so they are genuine equivalents of `CURDATE`, `CURTIME` and /// `NOW`, and the three `CURRENT_*` keywords work directly. /// /// Everything else is deliberately absent. SQLite has no `year()`, -/// `month()`, `day()`, `quarter()` or `extract()` -- only `strftime()` with a +/// `month()`, `day()`, `quarter()` or `extract()`, only `strftime()` with a /// format string, which requires the application to write the format itself /// and returns a zero-padded *string* rather than an integer. `timediff()` /// exists but returns a formatted delta string, not a count in a caller-chosen @@ -767,7 +768,7 @@ pub(crate) const SQLITE_TIMEDATE_FUNCTIONS: u32 = SQL_FN_TD_NOW /// changes. Same reason the `ALTER TABLE` and outer-join bitmaps are probed. /// /// Cached behind a `OnceLock` because core recomputes `SQL_KEYWORDS` on every -/// call — it cannot cache a value that is generic over the backend — and +/// call (it cannot cache a value that is generic over the backend), and /// walking SQLite's keyword table each time would be wasteful. The table is /// fixed at link time, so one walk is enough. pub(crate) fn sqlite_keywords() -> &'static [std::borrow::Cow<'static, str>] { @@ -791,7 +792,7 @@ pub(crate) fn sqlite_keywords() -> &'static [std::borrow::Cow<'static, str>] { if rc != rusqlite::ffi::SQLITE_OK || ptr.is_null() || len <= 0 { continue; } - // SAFETY: as above -- `ptr`/`len` describe a live, static, ASCII + // SAFETY: as above. `ptr`/`len` describe a live, static, ASCII // keyword that SQLite never mutates or frees. let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }; if let Ok(name) = std::str::from_utf8(bytes) { @@ -822,7 +823,7 @@ pub(super) fn get_info_raw( // support in the naive sense: `SQLExecDirectW` // / `SQLPrepareW` do translate `{fn NAME(...)}` escapes // (`stackable_odbc_core::escape::translate_escapes`, driven by - // `SqliteBackend::escape_dialect()` -- see `crate::escape_dialect`), so + // `SqliteBackend::escape_dialect()`; see `crate::escape_dialect`), so // `{fn ABS(x)}` becomes `ABS(x)` and succeeds, and the "under other // names" entries documented above are remapped (`UCASE`->`upper`, // `LCASE`->`lower`, `SUBSTRING`->`substr`, `ASCII`->`unicode`, plus @@ -830,11 +831,11 @@ pub(super) fn get_info_raw( // `SQL_TIMEDATE_FUNCTIONS` bitmap below). Names SQLite spells identically // to ODBC (`ABS`, `ROUND`, `CONCAT`, `LENGTH`, `IFNULL`, `CHAR`, ...) pass // through unchanged and already worked. Still deliberately untranslated: - // `CURRENT_DATE`/`CURRENT_TIME`/`CURRENT_TIMESTAMP` -- SQLite treats these + // `CURRENT_DATE`/`CURRENT_TIME`/`CURRENT_TIMESTAMP`. SQLite treats these // as bare keywords (`SELECT CURRENT_DATE();` is a syntax error), and a // name-only remap cannot drop the trailing `()` the `{fn ...()}` escape // always includes; see the `crate::escape_dialect` module doc comment. - // None of this is version-gated -- SQLite's version is fixed at compile + // None of this is version-gated: SQLite's version is fixed at compile // time by the `bundled` feature. match info_type { SQL_AGGREGATE_FUNCTIONS => Some(Ok(InfoValue::U32(SQLITE_AGGREGATE_FUNCTIONS))), @@ -855,7 +856,7 @@ pub(super) fn get_info_raw( } } -/// Every ODBC function this driver supports — which is exactly the set +/// Every ODBC function this driver supports, which is exactly the set /// `forward_ffi!` generates a C entry point for. /// /// Derived from core rather than hand-listed. `SQLGetFunctions` is what the @@ -984,7 +985,7 @@ mod tests { expected, "{} (DATA_TYPE {:?}): COLUMN_SIZE is {} but the \ backend-independent appendix formula for that DATA_TYPE \ - gives {} — the row is built from a different SqlDataType \ + gives {}; the row is built from a different SqlDataType \ than it reports", row.type_name(), row.data_type(), @@ -1029,7 +1030,7 @@ mod tests { /// The hooks take a connection because `SQLGetInfo` is a per-connection /// call and a data source's capabilities can differ by server. Every one /// this driver declares is a property of the linked SQLite library rather - /// than of the file opened, so any connection answers the same — but the + /// than of the file opened, so any connection answers the same. The /// answers must still be read through one, which is what an application /// has. fn test_connection() -> SqliteConnection { @@ -1102,11 +1103,11 @@ mod tests { (InfoType::ActiveEnvironments, Expected::U16(0)), (InfoType::MaxIdentifierLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), (InfoType::CatalogLocation, Expected::U16(0)), - // TransactionCapable is SQLUSMALLINT per spec, not SQLUINTEGER -- see + // TransactionCapable is SQLUSMALLINT per spec, not SQLUINTEGER. See // the matching comment on its arm in sqlite_get_info. (InfoType::TransactionCapable, Expected::U16(SQL_TC_DML as u16)), // --- U32 values --- - // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT -- see + // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT. See // the matching comment in stackable-odbc-core's default_get_info. // // SQL_UNSPECIFIED, not SQL_INSENSITIVE. This describes core's fetch @@ -1185,7 +1186,7 @@ mod tests { } /// `SQL_DBMS_VER` is a per-connection `Backend` hook now, so it is read - /// through a connection rather than off the pre-connect path — which + /// through a connection rather than off the pre-connect path, which /// cannot answer it, having no data source to name the version of. #[test] fn dbms_ver_is_well_formed() { @@ -1265,8 +1266,8 @@ mod tests { /// /// The negative half is the point, and is the same lesson /// `alter_table_capabilities_are_each_live_probed` records: a list that is - /// only extended when someone notices can understate forever. `""` — core's - /// old default, inherited rather than chosen — was exactly that, and had an + /// only extended when someone notices can understate forever. `""` (core's + /// old default, inherited rather than chosen) was exactly that, and had an /// application quoting `a$b`, a name SQLite parses bare. /// /// "Accepted" means more than "the CREATE parsed": the name must also come @@ -1345,12 +1346,12 @@ mod tests { /// style tests in this module do that for COLUMN_SIZE; this test is the /// one that exercises these live): /// - /// - `sign()` -- survives `SQLITE_ENABLE_MATH_FUNCTIONS` being compiled + /// - `sign()`: survives `SQLITE_ENABLE_MATH_FUNCTIONS` being compiled /// out of the `bundled` feature because it lives on the *core* /// functions page, not the math one. - /// - `soundex()` -- exists only because this build enables the + /// - `soundex()`: exists only because this build enables the /// non-default `SQLITE_SOUNDEX` compile flag. - /// - `octet_length()` -- claimed as the `SQL_FN_STR_OCTET_LENGTH` + /// - `octet_length()`: claimed as the `SQL_FN_STR_OCTET_LENGTH` /// equivalent. /// /// If a future `rusqlite`/`libsqlite3-sys` bump silently drops one of @@ -1366,7 +1367,7 @@ mod tests { /// the bundled library's `SQLITE_DEFAULT_FOREIGN_KEYS`. /// /// This goes through `connect` rather than opening a `rusqlite` connection - /// directly, because `connect` is where the guarantee lives — a raw + /// directly, because `connect` is where the guarantee lives. A raw /// connection would only re-test the dependency's build configuration, /// which is exactly what the driver stopped depending on. #[test] @@ -1443,7 +1444,7 @@ mod tests { match get_info(&sqlite_conn, info_type) { Ok(InfoValue::U16(v)) => assert!( v > 0, - "{info_type:?} reported 0 -- the connection limit was not read" + "{info_type:?} reported 0; the connection limit was not read" ), other => panic!("{info_type:?} unexpected: {other:?}"), } @@ -1524,7 +1525,7 @@ mod tests { } /// Every `SQL_SUBQUERIES` bit this driver claims, proved by preparing the - /// subquery form it describes — and the one it does not claim, proved by + /// subquery form it describes, and the one it does not claim, proved by /// the bundled library rejecting it. /// /// `SQL_SQ_QUANTIFIED` is the point. Core's default claimed it while this @@ -1586,8 +1587,8 @@ mod tests { /// /// Both halves are asserted, because each can fail independently: a raw /// list missing SQLite's own words, or a wiring mistake that leaves core - /// filtering something else. Properties rather than a fixed string — a - /// `rusqlite` bump may legitimately add a keyword, and pinning the value + /// filtering something else. Properties rather than a fixed string, since + /// a `rusqlite` bump may legitimately add a keyword, and pinning the value /// would turn that into a failure. #[test] fn keywords_hook_feeds_sql_keywords_with_odbc_words_removed() { @@ -1789,7 +1790,7 @@ mod tests { } /// Every `SQL_AT_*` bit this driver claims, proved by running the - /// `ALTER TABLE` it describes — and every bit it does *not* claim, proved + /// `ALTER TABLE` it describes, and every bit it does *not* claim, proved /// by the bundled library rejecting that syntax. /// /// The negative half is the point. A bitmap that only checks what it claims @@ -1877,7 +1878,7 @@ mod tests { ); assert!( conn.execute_batch(sql).is_err(), - "SQL_AT bit {bit:#x} is not claimed, but SQLite accepted it -- \ + "SQL_AT bit {bit:#x} is not claimed, but SQLite accepted it; \ SQLITE_ALTER_TABLE now understates and should be widened\n {sql}" ); } @@ -1925,7 +1926,7 @@ mod tests { ) .unwrap(); - // SQL_OJ_LEFT / SQL_OJ_RIGHT / SQL_OJ_FULL — the three join forms. + // SQL_OJ_LEFT / SQL_OJ_RIGHT / SQL_OJ_FULL: the three join forms. for (bit, sql) in [ ( SQL_OJ_LEFT, @@ -1948,7 +1949,7 @@ mod tests { assert!(n > 0, "SQL_OJ bit {bit:#x}: {sql} returned no rows"); } - // SQL_OJ_NESTED — an outer join whose operand is itself an outer join. + // SQL_OJ_NESTED: an outer join whose operand is itself an outer join. let nested: i64 = conn .query_row( "SELECT COUNT(*) FROM (l LEFT OUTER JOIN r ON l.id = r.id) \ @@ -1959,7 +1960,7 @@ mod tests { .expect("SQL_OJ_NESTED claimed but a nested outer join failed"); assert!(nested > 0, "SQL_OJ_NESTED probe returned no rows"); - // SQL_OJ_NOT_ORDERED — the ON-clause column order need not follow the + // SQL_OJ_NOT_ORDERED: the ON-clause column order need not follow the // table order in the FROM clause. let not_ordered: i64 = conn .query_row( @@ -1970,7 +1971,7 @@ mod tests { .expect("SQL_OJ_NOT_ORDERED claimed but a reversed ON clause failed"); assert!(not_ordered > 0, "SQL_OJ_NOT_ORDERED probe returned no rows"); - // SQL_OJ_INNER — the inner table of an outer join may also be used in + // SQL_OJ_INNER: the inner table of an outer join may also be used in // an inner join. let inner: i64 = conn .query_row( @@ -1982,7 +1983,7 @@ mod tests { .expect("SQL_OJ_INNER claimed but mixing an inner join in failed"); assert!(inner > 0, "SQL_OJ_INNER probe returned no rows"); - // SQL_OJ_ALL_COMPARISON_OPS — the ON clause takes any comparison + // SQL_OJ_ALL_COMPARISON_OPS: the ON clause takes any comparison // operator, not just equality. let any_op: i64 = conn .query_row( @@ -2136,7 +2137,7 @@ mod tests { produced, row.type_name(), "SQLITE_TYPE_INFO row {:?} (DATA_TYPE={:?}) is not reachable via \ - sqlite_bare_type_name (got {produced:?} instead) — no real column can \ + sqlite_bare_type_name (got {produced:?} instead); no real column can \ ever be reported under this TYPE_NAME", row.type_name(), row.data_type() @@ -2168,7 +2169,7 @@ mod tests { // This assertion is not masking a real possible divergence: both // sides of the TIME/TIMESTAMP comparison below // read the exact same `MAX_FRACTIONAL_SECONDS_PRECISION` constant, - // by design (see that constant's doc comment) -- SQLite has no + // by design (see that constant's doc comment): SQLite has no // schema-declarable temporal scale for a column to differ by, so // "the data source's maximum" and "an undeclared column's default" // are the same number *by construction*, not by coincidence. This @@ -2219,7 +2220,7 @@ mod tests { // 23 = 20 + 3 per the ODBC "Column Size" appendix's TIME/TIMESTAMP // formulas, evaluated at SQLite's documented 3-fractional-digit // ISO-8601 format (`YYYY-MM-DD HH:MM:SS.SSS`, format 4/7 at - // <https://www.sqlite.org/lang_datefunc.html>) -- see + // <https://www.sqlite.org/lang_datefunc.html>). See // `MAX_FRACTIONAL_SECONDS_PRECISION`'s doc comment in // `type_conversion.rs`. assert_eq!( @@ -2273,7 +2274,7 @@ mod tests { /// The macro's three components are cross-checked against the *full* /// `CARGO_PKG_VERSION` string rather than against the same three /// `CARGO_PKG_VERSION_*` variables the macro reads. Restating the macro's - /// own expansion would assert nothing -- it would pass even if the macro + /// own expansion would assert nothing: it would pass even if the macro /// wired PATCH where MINOR belongs, because both sides would carry the /// same mistake. Going through the combined string catches exactly that. #[test] @@ -2334,7 +2335,7 @@ mod tests { } /// RIGHT and FULL OUTER JOIN arrived in SQLite 3.39.0 and this build is - /// 3.53.2, so both are claimed -- verified by live probe, not assumed. + /// 3.53.2, so both are claimed, verified by live probe rather than assumed. #[test] fn sql92_join_operators_includes_right_and_full_outer() { assert_eq!( @@ -2365,7 +2366,7 @@ mod tests { /// The bundled build compiles out SQLITE_ENABLE_MATH_FUNCTIONS, so the /// whole trig/log/power set is gone. `sign()` survives because it is a - /// *core* function, not a math one -- the one flag that would be wrong if + /// *core* function, not a math one, the one flag that would be wrong if /// inferred from "math functions are off". #[test] fn numeric_functions_is_only_the_core_three() { @@ -2432,7 +2433,7 @@ mod tests { assert_eq!(SQLITE_SYSTEM_FUNCTIONS, SQL_FN_SYS_IFNULL); } - /// SQLite has no year()/month()/day() -- only strftime() with a format + /// SQLite has no year()/month()/day(), only strftime() with a format /// string, which is not an equivalent function. Only the current-date and /// current-time family is claimed. #[test] @@ -2473,7 +2474,7 @@ mod tests { /// /// The check that matters is this direction. `SQLGetFunctions` is what the /// Windows Driver Manager builds its dispatch table from, so claiming a - /// function core does not export hands it a null pointer to call — whereas + /// function core does not export hands it a null pointer to call, whereas /// staying silent about one merely means the DM does not use it. /// /// `SUPPORTED_FUNCTIONS` is the hand-written list `get_functions` used to diff --git a/src/backend/metadata.rs b/src/backend/metadata.rs index ee56d4b..fce91e5 100644 --- a/src/backend/metadata.rs +++ b/src/backend/metadata.rs @@ -228,7 +228,7 @@ fn tables_to_inspect( /// this list must name exactly the values [`tables`] can put in `TABLE_TYPE`. /// /// Upper case per the spec, which has applications specify table types in -/// upper case and the driver map them to whatever the data source needs -- +/// upper case and the driver map them to whatever the data source needs. /// SQLite spells its own lower case, and [`tables`] does that mapping. pub(super) fn table_types() -> Vec<std::borrow::Cow<'static, str>> { vec![ @@ -255,11 +255,11 @@ pub(super) fn tables( ) -> Result<Vec<TableRow>, SqliteError> { // ODBC spec: empty string is a valid (but useless for SQLite) filter; treat // as no-filter. Treat "%" (match-all wildcard) as no-filter too, to avoid - // LIKE '%' overhead -- an ordinary query is all that can arrive now. + // LIKE '%' overhead. An ordinary query is all that can arrive now. let table = query.table().filter(|s| !s.is_empty() && *s != "%"); // `TableType` is a value list, not a pattern, and core has already split it - // on commas and stripped the optional single quotes -- so what arrives is + // on commas and stripped the optional single quotes, so what arrives is // the parsed values, with empty ones already dropped. A lone "%" still // reaches here: the `SQL_ALL_TABLE_TYPES` enumeration core answers itself // additionally requires the other three arguments to be empty strings, so @@ -505,7 +505,7 @@ pub(super) fn foreign_keys( // NULL", which `ForeignKeyRow` enforces. `PRAGMA foreign_key_list` // leaves `to` NULL for an implicit reference (`REFERENCES parent` // with no column list), which SQLite defines as referencing the - // parent's PRIMARY KEY -- so the name is recoverable, and is + // parent's PRIMARY KEY, so the name is recoverable, and is // resolved rather than reported as a NULL the column cannot hold. let pk_column_name = match to_col { Some(c) => c, @@ -577,7 +577,7 @@ fn parent_pk_column( /// Rows are returned unsorted. Core orders them per spec by NON_UNIQUE, TYPE, /// INDEX_QUALIFIER, INDEX_NAME, ORDINAL_POSITION, and the table-stat row still /// comes first because its NON_UNIQUE is NULL and this driver reports -/// `SQL_NC_LOW` for `SQL_NULL_COLLATION` -- core's sorter takes NULL placement +/// `SQL_NC_LOW` for `SQL_NULL_COLLATION`. Core's sorter takes NULL placement /// from that hook rather than choosing for itself. /// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlstatistics-function> @@ -921,7 +921,7 @@ mod tests { /// These tests assert what the backend now owns: which rows exist and what /// each column holds. Column *order* and row *order* moved to core, which - /// converts these structs to the spec's layout and sorts them — so an + /// converts these structs to the spec's layout and sorts them, so an /// ordering assertion belongs at the FFI level, where core's sort has /// actually run, not here. See `ffi_integration_tests.rs`. fn setup_test_db() -> SqliteConnection { @@ -1029,7 +1029,7 @@ mod tests { #[test] fn tables_table_type_percent_with_table_wildcard_lists_tables() { let conn = setup_test_db(); - // TableType="%" with TableName="%" is not an enumeration — core only + // TableType="%" with TableName="%" is not an enumeration. Core only // treats "%" as `SQL_ALL_TABLE_TYPES` when the other three arguments // are empty strings, so this reaches the backend as an ordinary query // and must list actual tables and views. @@ -1210,7 +1210,7 @@ mod tests { /// `PKCOLUMN_NAME` is one of the columns the spec marks "not NULL", and /// `ForeignKeyRow` enforces that. `REFERENCES parent` with no column list /// leaves `PRAGMA foreign_key_list`'s `to` NULL, which this driver used to - /// report as a NULL `PKCOLUMN_NAME` — a value the column cannot hold. + /// report as a NULL `PKCOLUMN_NAME`, a value the column cannot hold. /// SQLite defines the implicit target as the parent's primary key, so the /// name is recovered rather than dropped. #[test] diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 827f657..3c470e6 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -83,7 +83,7 @@ unsafe fn exec_direct(stmt: *mut c_void, sql: &str) -> SqlReturn { /// /// These tests used to reach into `ConnectionHandle` for the underlying /// `rusqlite::Connection` and call `execute_batch` on it. Core's `handles` -/// module is `pub(crate)` now, so that route is gone — and driving setup +/// module is `pub(crate)` now, so that route is gone, and driving setup /// through the same entry points under test is the better answer anyway: a /// setup that silently stopped working fails here instead of leaving the test /// asserting against an empty table. @@ -93,8 +93,8 @@ unsafe fn exec_direct(stmt: *mut c_void, sql: &str) -> SqlReturn { /// string literal, which is what makes a plain split exact here. /// Both this and [`query_scalar_i64`] allocate their own statement handle /// rather than borrowing the caller's. The statement the test is asserting -/// about usually holds live state — a cursor, a prepared statement, bound -/// parameters — and running setup or a read-back over it would destroy exactly +/// about usually holds live state (a cursor, a prepared statement, bound +/// parameters), and running setup or a read-back over it would destroy exactly /// what the test is there to check. unsafe fn setup_sql(conn: *mut c_void, sql: &str) { unsafe { @@ -205,7 +205,7 @@ fn exec_direct_on_connected_handle_succeeds() { fn exec_direct_not_connected_returns_error() { unsafe { let (env, conn, stmt) = alloc_handles(); - // Don't connect — should fail. + // Don't connect: should fail. let ret = exec_direct(stmt, "SELECT 1"); assert_eq!(ret, SqlReturn::ERROR); cleanup(env, conn, stmt); @@ -372,7 +372,7 @@ fn get_data_datetime_column_handles_integer_and_real_storage() { // Row 2: dt stored as REAL Julian day 2451545.5 == 2000-01-02 // 00:00:00 UTC (verified against SQLite's own // `julianday('2000-01-02 00:00:00')`, which returns exactly this - // value — chosen because the Unix-epoch offset it implies, + // value, chosen because the Unix-epoch offset it implies, // 10958.0 days, multiplies back to a whole number of seconds with // no floating point rounding loss). assert_eq!( @@ -422,7 +422,7 @@ fn get_data_col_zero_returns_error() { let mut ind: isize = 0; let ret = ffi::fetch::sql_get_data::<SqliteBackend>( stmt, - 0, // bookmark column — not supported + 0, // bookmark column, not supported CDataType::SBigInt as i16, &mut buf as *mut i64 as *mut c_void, 8, @@ -475,7 +475,7 @@ fn close_cursor_then_fetch_returns_no_data() { SqlReturn::SUCCESS ); - // Close cursor — discards the result set entirely. + // Close cursor: discards the result set entirely. assert_eq!( ffi::cursor::sql_close_cursor::<SqliteBackend>(stmt), SqlReturn::SUCCESS @@ -649,7 +649,7 @@ unsafe fn assert_get_info_str(conn: *mut c_void, info_type: InfoType, expected: /// that produces a real value for them is `common_get_info_raw`, reached /// through the `get_info_raw` fallback in `sql_get_info_w`. The quoted case is /// core answering from `Backend::quoted_identifier_case`, so this pins the -/// value an application sees no matter which layer produced it — see +/// value an application sees no matter which layer produced it. See /// `quoted_identifiers_are_not_case_sensitive` for why it is `SQL_IC_MIXED`. /// /// The ten capability bitmaps below (`AggregateFunctions`, `Sql92Predicates`, @@ -674,7 +674,7 @@ fn get_info_named_but_unhandled_types_fall_back_to_get_info_raw() { // SQL_MULTIPLE_ACTIVE_TXN has no `odbc_sys::InfoType` variant at all, // so this raw path is the only way to reach it and the only place its - // value can be pinned -- `get_info_snapshot` iterates named types. + // value can be pinned; `get_info_snapshot` iterates named types. // "Y": each connection is its own rusqlite::Connection with its own // SQLite handle, so two can have transactions open at once. See // SqliteBackend::multiple_active_txn. @@ -697,7 +697,7 @@ fn get_info_named_but_unhandled_types_fall_back_to_get_info_raw() { } // SQLite capability bitmaps computed by SqliteBackend::get_info_raw - // (backend/info.rs) -- reference the same constants that function + // (backend/info.rs) reference the same constants that function // returns, rather than restating their numeric values here. assert_get_info_u32( conn, @@ -932,7 +932,7 @@ fn sql_tables_w_returns_tables_and_views() { } /// `SQL_ALL_CATALOGS`, `SQL_ALL_SCHEMAS` and `SQL_ALL_TABLE_TYPES` are all the -/// same sentinel — `"%"` — distinguished by which argument carries it while +/// same sentinel, `"%"`, distinguished by which argument carries it while /// the other two are *empty strings*. Core detects and serves all three; this /// pins what an application actually receives from this driver. const SQL_ALL_SENTINEL: &str = "%"; @@ -1021,7 +1021,7 @@ fn sql_tables_w_all_table_types_lists_table_and_view() { /// `SQL_ALL_CATALOGS` and `SQL_ALL_SCHEMAS` are empty result sets. /// /// Core answers both without consulting the backend, because -/// `supports_catalogs` and `supports_schemas` already say SQLite has neither — +/// `supports_catalogs` and `supports_schemas` already say SQLite has neither, /// which is why this driver implements neither `catalogs` nor `schemas`. #[test] fn sql_tables_w_all_catalogs_and_all_schemas_are_empty() { @@ -1039,7 +1039,7 @@ fn sql_tables_w_all_catalogs_and_all_schemas_are_empty() { } /// `"%"` in every argument is an ordinary match-everything query, not an -/// enumeration — the sentinel only triggers when the *other* arguments are +/// enumeration: the sentinel only triggers when the *other* arguments are /// empty strings. A detector keyed on `"%"` alone would answer this with a /// catalog list instead of the data source's tables. #[test] @@ -1291,7 +1291,7 @@ fn sql_col_attribute_w_count() { SqlReturn::SUCCESS ); - // Get SQL_DESC_COUNT (1001) — column_number is ignored + // Get SQL_DESC_COUNT (1001); column_number is ignored let mut num_attr: isize = 0; let ret = ffi::metadata::sql_col_attribute_w::<SqliteBackend>( stmt, @@ -1332,7 +1332,7 @@ fn sql_columns_w_returns_column_metadata() { ); assert_eq!(ret, SqlReturn::SUCCESS); - // Count rows — should be 3 columns (id, name, score) + // Count rows: should be 3 columns (id, name, score) let mut count = 0; loop { let ret = ffi::fetch::sql_fetch::<SqliteBackend>(stmt); @@ -1355,10 +1355,10 @@ fn sql_columns_w_result_set_reports_wvarchar_identifiers_and_narrow_data_type() // rather than a hand-built literal. Two properties matter enough to // assert at the ABI level via SQLDescribeColW: // - TABLE_NAME (and every identifier column) is SQL_WVARCHAR at width - // 128, not the old SQL_VARCHAR/255 -- the switch the Windows Driver + // 128, not the old SQL_VARCHAR/255. That is the switch the Windows Driver // Manager is strict about. // - DATA_TYPE (a SQL_SMALLINT column) has precision 5, not the old 50 - // -- a SMALLINT cannot have 50 digits of precision. + // (a SMALLINT cannot have 50 digits of precision). // A regression that reintroduces the old literals in // src/backend/metadata.rs would only be caught // by the Python integration suite without this test. @@ -1390,7 +1390,7 @@ fn sql_columns_w_result_set_reports_wvarchar_identifiers_and_narrow_data_type() let mut decimal: i16 = 0; let mut nullable: i16 = 0; - // Column 3: TABLE_NAME -- an identifier column. + // Column 3: TABLE_NAME, an identifier column. let ret = ffi::metadata::sql_describe_col_w::<SqliteBackend>( stmt, stackable_odbc_core::types::ColumnsResultCol::TableName.pos(), @@ -1406,7 +1406,7 @@ fn sql_columns_w_result_set_reports_wvarchar_identifiers_and_narrow_data_type() assert_eq!(data_type, SqlDataType::EXT_W_VARCHAR.0); assert_eq!(size, 128); - // Column 5: DATA_TYPE -- a SQL_SMALLINT column. + // Column 5: DATA_TYPE, a SQL_SMALLINT column. let ret = ffi::metadata::sql_describe_col_w::<SqliteBackend>( stmt, stackable_odbc_core::types::ColumnsResultCol::DataType.pos(), @@ -1459,7 +1459,7 @@ fn exec_direct_insert_then_select_roundtrip() { // Set up the table via raw rusqlite so we don't burn statement state. setup_sql(conn, "CREATE TABLE t (id INTEGER, name TEXT)"); - // INSERT through ODBC — row count must be 1. + // INSERT through ODBC: row count must be 1. assert_eq!( exec_direct(stmt, "INSERT INTO t VALUES (42, 'hello')"), SqlReturn::SUCCESS @@ -1474,7 +1474,7 @@ fn exec_direct_insert_then_select_roundtrip() { // No SQLCloseCursor here: an INSERT produces no result set, so no // cursor is open and the SELECT can reuse this handle directly. - // SELECT — verify the inserted row is readable. + // SELECT: verify the inserted row is readable. assert_eq!( exec_direct(stmt, "SELECT id, name FROM t"), SqlReturn::SUCCESS @@ -1927,7 +1927,7 @@ fn end_tran_begin_commit_roundtrip() { #[test] fn end_tran_begin_rollback_discards_row() { - // Begin a transaction, insert a row, rollback via SQLEndTran — table must be empty. + // Begin a transaction, insert a row, rollback via SQLEndTran. The table must be empty. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -2024,7 +2024,7 @@ fn fetch_scroll_next_advances_cursor() { #[test] fn fetch_scroll_non_next_returns_error() { - // SQL_FETCH_FIRST (2) is not supported — must return ERROR (HY106). + // SQL_FETCH_FIRST (2) is not supported, so it must return ERROR (HY106). unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -2193,7 +2193,7 @@ fn sql_primary_keys_w_no_table_filter_returns_all() { assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); setup_pk_fk_schema(conn); - // No table filter — should return PKs from both tables. + // No table filter, so PKs from both tables must come back. let ret = ffi::metadata::sql_primary_keys_w::<SqliteBackend>( stmt, std::ptr::null(), @@ -2547,7 +2547,7 @@ fn sql_cancel_with_open_cursor_does_not_close_it() { SqlReturn::SUCCESS ); - // Cancel — no-op, cursor stays open. + // Cancel: a no-op, cursor stays open. assert_eq!( ffi::cursor::sql_cancel::<SqliteBackend>(stmt), SqlReturn::SUCCESS @@ -2564,12 +2564,12 @@ fn sql_cancel_with_open_cursor_does_not_close_it() { } /// A statement handle carried to another thread so `SQLCancel` can be called -/// on it while the first thread executes — the cross-thread case the spec +/// on it while the first thread executes: the cross-thread case the spec /// singles out, and the only one where cancellation has anything to do. /// /// Sound because nothing here dereferences the pointer: it is the opaque token /// an application holds, which every core entry point validates through its -/// own registry. Core is built for exactly this — `sql_cancel` clones the +/// own registry. Core is built for exactly this: `sql_cancel` clones the /// backend's token out of the registry before touching anything else, so the /// handle staying valid is core's problem, not this test's. struct SendStmt(*mut c_void); @@ -2597,8 +2597,8 @@ fn sql_cancel_from_another_thread_stops_a_running_statement() { let stop = Arc::new(AtomicBool::new(false)); // Held across each `SQLCancel` so the main thread can be sure no cancel // is in flight before it reads the diagnostic. It matters because - // `SQLCancel`'s *idle* branch clears the statement's diagnostic queue — - // correctly, since a cancelled statement may be re-executed — so a + // `SQLCancel`'s *idle* branch clears the statement's diagnostic queue, + // correctly, since a cancelled statement may be re-executed, so a // cancel landing after `SQLExecDirectW` returned would wipe the very // `HY008` this test is looking for. let gate = Arc::new(Mutex::new(())); @@ -2712,7 +2712,7 @@ fn sql_statistics_w_returns_table_stat_row() { /// Sorting moved to core, which orders by NON_UNIQUE, TYPE, INDEX_QUALIFIER, /// INDEX_NAME, ORDINAL_POSITION. The table-stat row leads only because its /// NON_UNIQUE is NULL and this driver reports `SQL_NC_LOW` for -/// `SQL_NULL_COLLATION` — core takes NULL placement from that hook rather than +/// `SQL_NULL_COLLATION`. Core takes NULL placement from that hook rather than /// choosing for itself, so this is the test that ties the two together. It has /// to run through the FFI: the backend now returns rows unsorted. #[test] @@ -2789,7 +2789,7 @@ fn sql_statistics_w_orders_table_stat_row_first_then_unique_before_non_unique() /// /// `SQLStatistics` is one of only two catalog functions whose "the *TableName* /// argument was a null pointer" clause carries no **(DM)** marker, so the -/// driver owns it rather than the Driver Manager — and a table this function +/// driver owns it rather than the Driver Manager, and a table this function /// describes indexes of is not optional. This previously returned /// `SQL_SUCCESS` with no rows, which an application reads as "that table has /// no indexes". @@ -3005,7 +3005,7 @@ fn get_data_truncates_string_returns_success_with_info() { #[test] fn fetch_after_no_data_returns_no_data_again() { // After a result set is exhausted (SQLFetch returns NO_DATA), subsequent - // SQLFetch calls must also return NO_DATA — not ERROR or panic. + // SQLFetch calls must also return NO_DATA, not ERROR or panic. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -3052,10 +3052,10 @@ fn exec_direct_reuse_after_error() { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); - // Invalid SQL — must fail. + // Invalid SQL: must fail. assert_eq!(exec_direct(stmt, "NOT VALID SQL AT ALL"), SqlReturn::ERROR); - // Valid query on the same handle — must succeed. + // Valid query on the same handle: must succeed. assert_eq!(exec_direct(stmt, "SELECT 1"), SqlReturn::SUCCESS); assert_eq!( ffi::fetch::sql_fetch::<SqliteBackend>(stmt), @@ -3081,7 +3081,7 @@ fn exec_direct_reuse_after_error() { } // --------------------------------------------------------------------------- -// P2: SQLColAttributeW — nullable, precision, octet_length via FFI +// P2: SQLColAttributeW (nullable, precision, octet_length via FFI) // --------------------------------------------------------------------------- #[test] @@ -3096,7 +3096,7 @@ fn sql_col_attribute_w_reports_each_columns_real_nullability() { // // The third is the one worth stating. `sqlite3_table_column_metadata` // answers nothing for a computed column, so the driver genuinely cannot - // determine it — and the spec has a value for exactly that, rather than + // determine it, and the spec has a value for exactly that, rather than // requiring a guess. This driver used to report SQL_NULLABLE for all // three. unsafe { @@ -3209,7 +3209,7 @@ fn sql_col_attribute_w_returns_octet_length_for_integer() { #[test] fn close_cursor_twice_returns_error() { - // The second SQLCloseCursor call must return ERROR (SQLSTATE 24000 — invalid + // The second SQLCloseCursor call must return ERROR (SQLSTATE 24000, invalid // cursor state) because there is no open cursor after the first close. unsafe { let (env, conn, stmt) = alloc_handles(); @@ -3225,12 +3225,12 @@ fn close_cursor_twice_returns_error() { SqlReturn::SUCCESS ); - // First close — cursor is open, must succeed. + // First close: cursor is open, must succeed. assert_eq!( ffi::cursor::sql_close_cursor::<SqliteBackend>(stmt), SqlReturn::SUCCESS ); - // Second close — no cursor open, must return ERROR (24000). + // Second close: no cursor open, must return ERROR (24000). assert_eq!( ffi::cursor::sql_close_cursor::<SqliteBackend>(stmt), SqlReturn::ERROR @@ -3271,7 +3271,7 @@ fn num_result_cols_after_prepare_before_execute() { } // --------------------------------------------------------------------------- -// P3: SQLGetDiagFieldW — field-by-field after an error +// P3: SQLGetDiagFieldW (field-by-field after an error) // --------------------------------------------------------------------------- #[test] @@ -3456,7 +3456,7 @@ fn get_diag_field_message_text_long_message_does_not_panic() { } // --------------------------------------------------------------------------- -// P3: SQLGetEnvAttrW — ODBC version roundtrip +// P3: SQLGetEnvAttrW (ODBC version roundtrip) // --------------------------------------------------------------------------- #[test] @@ -3856,7 +3856,7 @@ fn exec_direct_sends_bound_parameters() { assert_eq!( ffi::fetch::sql_fetch::<SqliteBackend>(stmt), SqlReturn::SUCCESS, - "no row returned — the bound parameter was not sent" + "no row returned; the bound parameter was not sent" ); let mut out: i64 = 0; let mut ind: isize = 0; @@ -4135,7 +4135,7 @@ fn data_at_execution_insert() { SqlReturn::SUCCESS ); - // SQLParamData: no more pending params — should execute the INSERT and return SUCCESS. + // SQLParamData: no more pending params, so it should execute the INSERT and return SUCCESS. let mut value_ptr2: *mut c_void = std::ptr::null_mut(); assert_eq!( ffi::params::sql_param_data::<SqliteBackend>(stmt, &mut value_ptr2), @@ -4322,7 +4322,7 @@ fn txn_isolation_accepts_only_the_level_sqlite_implements() { // The spec assigns this check to the driver: the Driver Manager validates // only attributes "that accept a discrete set of values". An application // that asked for READ COMMITTED previously got SQL_SUCCESS and serializable - // behaviour anyway -- it had no way to find out it had not been honoured. + // behaviour anyway; it had no way to find out it had not been honoured. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -4719,11 +4719,11 @@ fn timestamp_column_stored_as_text_read_as_type_timestamp() { // a string (or vice versa), and conversion bitmaps that returned 0 (which // makes the Windows DM block SQLGetData with HYC00). Line coverage stayed // green throughout, because the code path that produced the wrong answer -// ran constantly -- nobody had asserted what it returned for every info +// ran constantly, and nobody had asserted what it returned for every info // type, just the ones a test happened to name. // // These two tests close that gap by iterating every `InfoType` odbc-sys -// compiles (derived from `info_type_from_raw`, not a hand-copied list -- see +// compiles (derived from `info_type_from_raw`, not a hand-copied list; see // `stackable_odbc_core::conformance`) through the real `sql_get_info_w` FFI entry // point, against the real `SqliteBackend`, connected and pre-connect. @@ -4769,7 +4769,7 @@ fn get_info_every_named_info_type_has_the_declared_shape_connected() { /// info types (e.g. `SQL_DRIVER_ODBC_VER`) before `SQLDriverConnectW`, which /// routes through `SqliteBackend::get_info_pre_connect` instead of /// `get_info`. `sqlite_get_info` backs both, so this is expected to match -/// the connected test above for every info type -- asserted separately +/// the connected test above for every info type, asserted separately /// because the two call sites in `sql_get_info_w` are independent code /// paths that could regress independently. #[test] @@ -4801,7 +4801,7 @@ fn get_info_every_named_info_type_has_the_declared_shape_pre_connect() { } /// Property 2: no genuine `SQL_CONVERT_*` code ever returns 0 through -/// `SqliteBackend` -- per `AGENTS.md`, a `0` conversion bitmap is what makes +/// `SqliteBackend`. Per `AGENTS.md`, a `0` conversion bitmap is what makes /// the Windows Driver Manager block `SQLGetData` with `HYC00`. #[test] fn get_info_no_genuine_convert_info_type_ever_returns_zero() { @@ -4818,7 +4818,7 @@ fn get_info_no_genuine_convert_info_type_ever_returns_zero() { ); assert_ne!( value, 0, - "raw SQL_CONVERT_* info type {info_type} returned 0 -- this is the \ + "raw SQL_CONVERT_* info type {info_type} returned 0; this is the \ exact shape that makes the Windows Driver Manager block SQLGetData \ with HYC00 (AGENTS.md)" ); @@ -4942,7 +4942,7 @@ fn escape_fn_curdate_executes_as_sqlite_date() { /// use. `EscapeDialect::rewrite_scalar_fn` replaces the whole escape, which is /// what emitting a bare keyword requires. /// -/// Only the shape is asserted -- these are clock values. +/// Only the shape is asserted, these being clock values. #[test] fn escape_bare_keyword_datetime_fns_execute() { unsafe { @@ -4962,7 +4962,7 @@ fn escape_bare_keyword_datetime_fns_execute() { assert_eq!( exec_direct(stmt, sql), SqlReturn::SUCCESS, - "{sql} failed to translate -- the escape's trailing () most \ + "{sql} failed to translate; the escape's trailing () most \ likely reached SQLite" ); assert_eq!( @@ -5059,7 +5059,7 @@ fn escape_fn_now_executes_as_sqlite_datetime() { /// eagerly; see `SqliteBackend::cursor_commit_behavior` for why that, and not /// SQLite's own semantics, decides the answer. SQLite would abort a pending /// read on ROLLBACK (`SQLITE_ABORT`, >= 3.7.11), which would be -/// `SQL_CB_CLOSE` — but this driver never has one pending. +/// `SQL_CB_CLOSE`, but this driver never has one pending. #[test] fn end_tran_cursor_behaviour_is_preserve_for_commit_and_rollback() { use stackable_odbc_core::types::{SQL_CB_PRESERVE, SQL_CURSOR_ROLLBACK_BEHAVIOR}; @@ -5123,7 +5123,7 @@ fn close_cursor_after_dml_returns_no_cursor_open() { SqlReturn::ERROR ); - // The handle is still usable — the rejected close changed nothing. + // The handle is still usable: the rejected close changed nothing. assert_eq!( exec_direct(stmt, "SELECT v FROM dml_cc"), SqlReturn::SUCCESS diff --git a/src/lib.rs b/src/lib.rs index ec20194..1889c5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,8 +2,8 @@ //! [`stackable_odbc_core`] framework. //! //! This crate compiles to a C dynamic library (`cdylib`) that an ODBC Driver -//! Manager (unixODBC on Linux, the built-in DM on Windows) loads at runtime — -//! it is not used as a normal Rust dependency. All the ODBC C ABI entry points +//! Manager (unixODBC on Linux, the built-in DM on Windows) loads at runtime. +//! It is not used as a normal Rust dependency. All the ODBC C ABI entry points //! are generated by [`stackable_odbc_core::forward_ffi!`] from the [`SqliteBackend`] //! implementation; the Driver Manager translates ANSI calls, so only the //! Unicode (`W`) functions are exported. diff --git a/src/type_conversion.rs b/src/type_conversion.rs index b780d4a..d25def4 100644 --- a/src/type_conversion.rs +++ b/src/type_conversion.rs @@ -32,10 +32,10 @@ use stackable_odbc_core::types::{ColumnValue, SqlDataType, column_size}; /// `column_value_to_rusqlite`'s current 9-digit-nanosecond rendering) still /// round-trips as data: SQLite text storage is unbounded, so the extra /// digits are neither rejected nor truncated in storage, only -/// under-reported by `SQL_DESC_DISPLAY_SIZE`/`COLUMN_SIZE` -- the same kind -/// of "declared vs. actual" gap every other undeclared-length default in +/// under-reported by `SQL_DESC_DISPLAY_SIZE`/`COLUMN_SIZE`. That is the same +/// kind of "declared vs. actual" gap every other undeclared-length default in /// `default_precision_for_type` already carries, for the same reason (no -/// real schema constraint to consult). That is an accepted, general +/// real schema constraint to consult). It is an accepted, general /// limitation of describing a dynamically typed column ahead of fetching /// it, not something this specific constant introduces. pub(crate) const MAX_FRACTIONAL_SECONDS_PRECISION: i16 = 3; @@ -54,8 +54,8 @@ pub(crate) const MAX_FRACTIONAL_SECONDS_PRECISION: i16 = 3; // is already handled generically by `stackable-odbc-core` (`ColumnValue::String` converts // to any C datetime type per the ODBC conversion matrix); the two numeric // encodings are a SQLite-specific convention, so they are decoded here, at -// fetch time, where the column's declared type is known -- `stackable-odbc-core` must -// not carry this backend-specific knowledge (see its `write_column_value` +// fetch time, where the column's declared type is known. `stackable-odbc-core` +// must not carry this backend-specific knowledge (see its `write_column_value` // doc comment). /// Convert a [`ColumnValue`] (from ODBC parameter binding) to a [`rusqlite::types::Value`] @@ -123,7 +123,7 @@ pub fn column_value_to_rusqlite(value: &ColumnValue) -> Value { // text correctly in arithmetic contexts). ColumnValue::Decimal(s) => Value::Text(s.clone()), // New ColumnValue variants are not natively representable in SQLite. - // TODO(spec): HYC00 — optional feature not implemented; cannot store complex types in SQLite. + // TODO(spec): HYC00 (optional feature not implemented); cannot store complex types in SQLite. _ => { tracing::warn!( value = ?value, @@ -146,7 +146,7 @@ pub fn column_value_to_rusqlite(value: &ColumnValue) -> Value { /// A column declared `DATE`/`TIME`/`DATETIME`/`TIMESTAMP` is described to the /// application as the corresponding ODBC datetime SQL type, but SQLite may /// still have stored the value as `INTEGER` (epoch seconds) or `REAL` (Julian -/// day) rather than text -- see the module-level doc comment above. Those two +/// day) rather than text (see the module-level doc comment above). Those two /// cases are decoded here into a proper `ColumnValue::Date`/`Time`/`Timestamp` /// so `stackable-odbc-core`, which holds no SQLite-specific knowledge, only ever sees a /// correctly typed value. @@ -201,8 +201,8 @@ struct DecodedDateTime { impl DecodedDateTime { /// Narrow to whichever `ColumnValue` variant `sql_type` calls for. /// - /// `sql_type` is always one of `DATE`/`TIME`/`TIMESTAMP` here -- the only - /// values the caller matches on before reaching this point -- so the + /// `sql_type` is always one of `DATE`/`TIME`/`TIMESTAMP` here (the only + /// values the caller matches on before reaching this point), so the /// fallback arm is unreachable in practice; it maps to `Timestamp` rather /// than panicking, since `SqlDataType` is not our enum to exhaustively /// match without a wildcard. @@ -220,7 +220,7 @@ impl DecodedDateTime { // `decode_epoch_seconds` always passes 0 nanos (an INTEGER // epoch-seconds value has no sub-second part), but // `decode_julian_day`'s REAL encoding can carry a genuine - // fraction -- `self.fraction` is real data, not a placeholder. + // fraction, so `self.fraction` is real data, not a placeholder. fraction: self.fraction, }, _ => ColumnValue::Timestamp { @@ -263,7 +263,7 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { /// already isolated by the caller, into a [`DecodedDateTime`]. /// /// Returns `None` if the resulting year does not fit `SQL_TIMESTAMP_STRUCT.year` -/// (`i16`) -- see [`decode_epoch_seconds`] for how callers handle that. +/// (`i16`). See [`decode_epoch_seconds`] for how callers handle that. fn timestamp_from_epoch_seconds(total_seconds: i64, nanos: u32) -> Option<DecodedDateTime> { let days = total_seconds.div_euclid(86_400); let secs_of_day = total_seconds.rem_euclid(86_400); @@ -296,7 +296,7 @@ fn decode_epoch_seconds(epoch_seconds: i64, sql_type: SqlDataType) -> Option<Col } /// Decode a SQLite `REAL` datetime column (Julian day number, days since noon -/// on proleptic-Gregorian -4713-11-24 -- the convention SQLite's own +/// on proleptic-Gregorian -4713-11-24, the convention SQLite's own /// `julianday()` function uses) into the [`ColumnValue`] variant `sql_type` /// calls for. /// @@ -611,7 +611,7 @@ mod tests { // 2451545.0 is 2000-01-01 12:00:00 UTC exactly (see // julian_day_real_decodes_to_timestamp below); adding a quarter of a // second's worth of days exercises the fractional-seconds path that - // only the REAL (Julian day) encoding can produce for TIME -- + // only the REAL (Julian day) encoding can produce for TIME. // `decode_epoch_seconds` (INTEGER) never has a nonzero fraction to // decode, so `DecodedDateTime::fraction` must be threaded through // rather than dropped. diff --git a/windows/WINDOWS.md b/windows/WINDOWS.md index 2baa1ba..96ce2aa 100644 --- a/windows/WINDOWS.md +++ b/windows/WINDOWS.md @@ -38,7 +38,7 @@ uv run --with pywinrm python3 test/windows_test.py --help The VM lifecycle section below uses QEMU/KVM via libvirt, and the test script auto-discovers the VM IP from libvirt DHCP leases. If you are running Windows -in a different hypervisor, the test script still works — just pass the VM's IP +in a different hypervisor, the test script still works; just pass the VM's IP directly with `--host`: ```bash @@ -51,7 +51,7 @@ they differ from the defaults. ### OpenSSL legacy provider -WinRM uses NTLM authentication, which requires MD4 — disabled by default in +WinRM uses NTLM authentication, which requires MD4, disabled by default in modern OpenSSL. The test script automatically sets `OPENSSL_CONF` to point at `windows/openssl_legacy.cnf`, which enables the legacy provider. @@ -62,7 +62,7 @@ that you haven't overridden `OPENSSL_CONF` in your environment. ### Prerequisites -QEMU/KVM and libvirt must be installed and working as system services — +QEMU/KVM and libvirt must be installed and working as system services: `nix-shell` only provides Ansible and the Python bindings, not the virtualisation stack itself. Verify with: @@ -97,7 +97,7 @@ pipx install uv ### Creating the VM ```bash -# Set once — point to your Windows Server 2022 evaluation ISO. +# Set once, pointing at your Windows Server 2022 evaluation ISO. # Download from: https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022 export WINDOWS_ISO=~/Downloads/SERVER_EVAL_x64FRE_en-us.iso @@ -123,7 +123,7 @@ virt-viewer --connect qemu:///system stackable-odbc-test virsh --connect qemu:///system shutdown stackable-odbc-test ``` -The VM definition and disk persist — next `start` is fast. +The VM definition and disk persist, so the next `start` is fast. ### Tearing down completely @@ -174,7 +174,7 @@ needed. odbcconf.exe /A {INSTALLDRIVER "stackable_odbc_sqlite|Driver=C:\Users\Administrator\Downloads\stackable_odbc_sqlite.dll|Setup=C:\Users\Administrator\Downloads\stackable_odbc_sqlite.dll|"} ``` -Both `Driver=` and `Setup=` must point to the same DLL — it exports both the +Both `Driver=` and `Setup=` must point to the same DLL, which exports both the ODBC API functions and the `ConfigDSNW` setup entry point. ### Creating a DSN @@ -199,7 +199,7 @@ Open `%SystemRoot%\System32\odbcad32.exe` (64-bit) and confirm: - **Drivers tab**: `stackable_odbc_sqlite` is listed - **User DSN tab**: `MySQLite` (or whatever DSN name you chose) is listed -- Selecting the driver under "Add" should produce no error (but also no dialog — this is expected for a headless driver) +- Selecting the driver under "Add" should produce no error (but also no dialog, which is expected for a headless driver) ### Unregistering @@ -221,7 +221,7 @@ reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "stackable_odbc_sql ### PowerShell smoke test -PowerShell's `System.Data.Odbc` is built into .NET — no extra tools needed. +PowerShell's `System.Data.Odbc` is built into .NET, so no extra tools are needed. This example is self-contained: it creates its own table, queries it, and cleans up. The driver must be registered first (done automatically by the test script). From fdb003d965807563ac9594feb4e60d69d3baeacb Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Sat, 1 Aug 2026 23:13:07 +0200 Subject: [PATCH 30/50] test: move the suites under integration-tests/ and factor out the shared script paths `test/` and `windows/` were two top-level directories for one concern, and `test/` mixed four different things: the shell entry points, the SQL fixture, the pyodbc suite, and three generated files sitting in the same directory as the sources. The layout now matches stackable-odbc-trino's, minus everything that only exists because Trino needs a server: integration-tests/ setup.sh, run-tests.sh wrappers scripts/ lib.sh + the logic suites/ create_test_db.sql, test_integration.py generated/ gitignored wholesale windows/ WINDOWS.md, windows_test.py, vm/ There is no stack/, no compose file and no scripts/teardown.sh: SQLite is a file and rusqlite links its own copy, so there is nothing to stand up or tear down. That is also why this suite gates every pull request while Trino's cannot. The script cleanup: - Both scripts derived PROJECT_DIR, DRIVER_PATH and DB_PATH separately, and disagreed about which directory DB_PATH lived in relative to the script. They now source scripts/lib.sh, which owns those and the ODBCSYSINI/ODBCINI export, the driver build, and a setup precondition check. - Generated output moves out of the source directory into generated/, ignored by a `*` .gitignore rather than by naming each file. All of it embeds absolute paths, so a committed copy is wrong for everyone but its author. - run-tests.sh forwarded any unrecognised argument to windows_test.py even without --windows, so a typo'd flag produced a full green run that had ignored it. Unknown arguments are now rejected unless --windows is given. - run-tests.sh required setup.sh to have been run and said so nowhere; it now checks and points at setup.sh instead of failing inside pyodbc. - Both take --help, printed from the header comment block by lib.sh's usage() rather than a hardcoded line range that truncates as soon as a line is added. - setup.sh gained --skip-build, and rejects unknown arguments. shellcheck now runs with -x so it follows lib.sh, the same reason the Trino repository passes it; without it every sourcing script reports SC1091 for a file that is right there. Also fixes two path references that were already stale before the move: windows/vm/start.yaml pointed at `test/sqlite/windows_test.py`, a path this repository has never had, and test_integration.py's usage line named itself under test/. Verified by running setup.sh and run-tests.sh: 23 pyodbc tests pass DSN-less and again through the DSN, the require_setup guard fires with the database removed, and unknown arguments exit 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/build.yaml | 4 +- .github/workflows/release.yaml | 4 +- .gitignore | 4 +- .pre-commit-config.yaml | 5 +- AGENTS.md | 27 +++--- README.md | 12 +-- integration-tests/README.md | 86 +++++++++++++++++++ integration-tests/generated/.gitignore | 4 + integration-tests/run-tests.sh | 3 + integration-tests/scripts/lib.sh | 59 +++++++++++++ integration-tests/scripts/run-tests.sh | 81 +++++++++++++++++ integration-tests/scripts/setup.sh | 63 ++++++++++++++ integration-tests/setup.sh | 3 + .../suites}/create_test_db.sql | 0 .../suites}/test_integration.py | 4 +- .../windows}/WINDOWS.md | 15 ++-- .../windows}/openssl_legacy.cnf | 0 .../windows-install-config/Autounattend.xml | 0 .../windows-install-config/redhat-drivers.crt | 0 .../windows}/vm/inventory.ini | 0 .../windows}/vm/shell.nix | 0 .../windows}/vm/start.yaml | 2 +- .../windows-vm-network-internet.xml.j2 | 0 .../vm/templates/windows-vm-network.xml.j2 | 0 .../vm/templates/windows-vm-volume.xml.j2 | 0 .../windows}/vm/templates/windows-vm.xml.j2 | 0 .../windows}/windows_test.py | 22 ++--- test/.gitignore | 3 - test/run-tests.sh | 68 --------------- test/setup.sh | 39 --------- 30 files changed, 354 insertions(+), 154 deletions(-) create mode 100644 integration-tests/README.md create mode 100644 integration-tests/generated/.gitignore create mode 100755 integration-tests/run-tests.sh create mode 100644 integration-tests/scripts/lib.sh create mode 100755 integration-tests/scripts/run-tests.sh create mode 100755 integration-tests/scripts/setup.sh create mode 100755 integration-tests/setup.sh rename {test => integration-tests/suites}/create_test_db.sql (100%) rename {test => integration-tests/suites}/test_integration.py (98%) rename {windows => integration-tests/windows}/WINDOWS.md (94%) rename {windows => integration-tests/windows}/openssl_legacy.cnf (100%) rename {windows => integration-tests/windows}/vm/files/windows-install-config/Autounattend.xml (100%) rename {windows => integration-tests/windows}/vm/files/windows-install-config/redhat-drivers.crt (100%) rename {windows => integration-tests/windows}/vm/inventory.ini (100%) rename {windows => integration-tests/windows}/vm/shell.nix (100%) rename {windows => integration-tests/windows}/vm/start.yaml (98%) rename {windows => integration-tests/windows}/vm/templates/windows-vm-network-internet.xml.j2 (100%) rename {windows => integration-tests/windows}/vm/templates/windows-vm-network.xml.j2 (100%) rename {windows => integration-tests/windows}/vm/templates/windows-vm-volume.xml.j2 (100%) rename {windows => integration-tests/windows}/vm/templates/windows-vm.xml.j2 (100%) rename {test => integration-tests/windows}/windows_test.py (94%) delete mode 100644 test/.gitignore delete mode 100755 test/run-tests.sh delete mode 100755 test/setup.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 01305ca..2a9b81d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -101,9 +101,9 @@ jobs: - name: Run SQLite integration tests run: | - ./test/setup.sh + ./integration-tests/setup.sh # --skip-cargo-test: the pre-commit job above already ran it. - ./test/run-tests.sh --skip-cargo-test + ./integration-tests/run-tests.sh --skip-cargo-test windows-cross-compile: name: Cross-compile Windows DLL diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3377c86..4b10c1f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -68,8 +68,8 @@ jobs: - name: Run SQLite integration tests run: | - ./test/setup.sh - ./test/run-tests.sh + ./integration-tests/setup.sh + ./integration-tests/run-tests.sh build-and-package: name: Build and package SQLite release archives diff --git a/.gitignore b/.gitignore index 9547902..5beef67 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,8 @@ tags # Release packaging output packaging/dist/ -# Generated by test/setup.sh -test/test.db +# integration-tests/generated/ has its own .gitignore; everything setup.sh +# writes there embeds absolute paths. # Python bytecode from the test scripts __pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 701962c..6700cf4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,10 @@ repos: rev: 2491238703a5d3415bb2b7ff11388bf775372f29 # 0.10.0 hooks: - id: shellcheck - args: ["--severity=info"] + # -x follows `source`d files. The integration-test scripts share + # lib.sh, and without it every one of them reports SC1091 for a file + # that is right there and checkable. + args: ["--severity=info", "-x"] - repo: local hooks: diff --git a/AGENTS.md b/AGENTS.md index fd32647..f6e74b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,8 +33,8 @@ cargo test # unit + FFI tests; needs no server cargo clippy --all-targets -- -D warnings pre-commit run --all-files # the gate; run before every commit -./test/setup.sh # build driver, create test.db, write ODBC config -./test/run-tests.sh # run the integration suite +./integration-tests/setup.sh # build driver, create the DB, write ODBC config +./integration-tests/run-tests.sh # run the integration suite ``` ## Relationship to stackable-odbc-core @@ -534,21 +534,24 @@ with no data source open. ### Integration tests ```bash -./test/setup.sh # build, create test/test.db, write odbc.ini/odbcinst.ini -./test/run-tests.sh # pyodbc suite through real unixODBC, then cargo test -./test/run-tests.sh --windows # also run the Windows VM suite -./test/run-tests.sh --skip-cargo-test # pyodbc only; what CI passes +./integration-tests/setup.sh # build, create the database, write the ODBC config +./integration-tests/run-tests.sh # pyodbc suite through real unixODBC, then cargo test +./integration-tests/run-tests.sh --windows # also run the Windows VM suite +./integration-tests/run-tests.sh --skip-cargo-test # pyodbc only; what CI passes ``` -`test/setup.sh` and `test/run-tests.sh` regenerate `test/odbc.ini`, -`test/odbcinst.ini` and `test/test.db`; all three are gitignored because they -hold absolute paths. +Both are wrappers; the logic is in `integration-tests/scripts/`, with the paths +and helpers they share in `scripts/lib.sh`. Everything `setup.sh` writes lands +in `integration-tests/generated/`, which is gitignored wholesale because all of +it embeds absolute paths. See +[integration-tests/README.md](integration-tests/README.md) for the layout and +why the pyodbc suite is run twice. ### Windows VM tests -See [windows/WINDOWS.md](windows/WINDOWS.md). Requires a provisioned libvirt VM; -`test/windows_test.py` runs the same pyodbc suite over WinRM, DSN-less and then -via DSN. +See [integration-tests/windows/WINDOWS.md](integration-tests/windows/WINDOWS.md). +Requires a provisioned libvirt VM; `integration-tests/windows/windows_test.py` +runs the same pyodbc suite over WinRM, DSN-less and then via DSN. ### Benchmarks diff --git a/README.md b/README.md index 070f890..cd253ae 100644 --- a/README.md +++ b/README.md @@ -226,13 +226,15 @@ The integration suite goes one layer further out and runs through real unixODBC, using Python's `pyodbc` exactly like a normal application would: ```bash -./test/setup.sh # build the driver, create test/test.db, write the ODBC config -./test/run-tests.sh # run the pyodbc suite, then cargo test +./integration-tests/setup.sh # build the driver, create the database, write the ODBC config +./integration-tests/run-tests.sh # run the pyodbc suite, then cargo test ``` -Both are run on every pull request. `./test/run-tests.sh --windows` additionally -runs the same suite inside a Windows VM; see -[windows/WINDOWS.md](windows/WINDOWS.md) for how to provision one. +Both are run on every pull request. `run-tests.sh --windows` additionally runs +the same suite inside a Windows VM; see +[integration-tests/README.md](integration-tests/README.md) for what is covered +and [integration-tests/windows/WINDOWS.md](integration-tests/windows/WINDOWS.md) +for how to provision one. For the architecture, the conventions and the full testing reference, see [AGENTS.md](AGENTS.md). diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 0000000..d4dda87 --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,86 @@ +# Integration tests + +Everything that exercises the driver from outside the Rust crate: through real +unixODBC on Linux, and through the real Windows Driver Manager in a VM. + +There is no service to start. SQLite is a file, and `rusqlite` links its own +copy of it into the driver, so the whole suite runs on a bare runner in seconds. +That is exactly why it gates every pull request while the Trino driver's +equivalent cannot. + +```bash +./integration-tests/setup.sh # build the driver, create the database, write the ODBC config +./integration-tests/run-tests.sh # pyodbc through unixODBC, then cargo test +``` + +Both take `--help`. + +## Layout + +| Path | What it holds | +|------|---------------| +| `setup.sh`, `run-tests.sh` | Wrappers. The logic is in `scripts/` | +| `scripts/lib.sh` | The paths and helpers both scripts share. Sourced, never executed | +| `scripts/setup.sh` | Builds the driver, creates `test.db`, writes `odbc.ini` / `odbcinst.ini` | +| `scripts/run-tests.sh` | Runs the suites | +| `suites/create_test_db.sql` | The schema and rows every suite reads | +| `suites/test_integration.py` | The pyodbc suite, run once per connection style | +| `generated/` | Everything `setup.sh` writes. Gitignored | +| `windows/` | The VM suite, its libvirt definitions, and [WINDOWS.md](windows/WINDOWS.md) | + +`generated/` is ignored rather than committed because all three files it holds +name absolute paths: the driver's `.so`, the database. None of them survives +being moved to another checkout, so a committed copy would be wrong for +everyone but its author. + +## What gets run + +`run-tests.sh` runs the pyodbc suite **twice**, against the same database: + +- **DSN-less**, `Driver=...;Database=...`, which exercises this driver's own + connection-string parsing. +- **Via a DSN**, `DSN=test_sqlite`, where the Driver Manager resolves the + keywords out of `odbc.ini` first. + +They are separate runs because they fail separately. A driver that reads its +parameters correctly can still be unreachable through a DSN, and that is a +configuration most applications actually use. + +It then runs `cargo test`, so that one command gives a developer the whole +suite. CI passes `--skip-cargo-test`, since its pre-commit job has already run +exactly that via the `cargo-test` hook. + +## Options + +| Flag | Effect | +|------|--------| +| `--skip-build` | Reuse the driver already built. Forwarded to `windows_test.py`, whose build is a separate cross-compile | +| `--skip-cargo-test` | Run the pyodbc suites only. What CI passes | +| `--windows` | Additionally run the suite inside the Windows VM | + +Any other argument is forwarded to `windows_test.py` (`--host`, `--gateway`, +`--user`, `--password`) and so is rejected without `--windows`: a flag +forwarded to a script that never runs is a flag silently ignored. + +## Windows + +`windows/windows_test.py` deploys the cross-compiled DLL to a provisioned +libvirt VM over WinRM, registers it, and runs the same +`suites/test_integration.py` through the Windows Driver Manager, DSN-less and +then via a DSN. The Windows DM is much stricter than unixODBC and tends to fail +silently, so this is measured rather than assumed. + +See [windows/WINDOWS.md](windows/WINDOWS.md) for provisioning the VM. + +## Interactively + +`setup.sh` prints these at the end: + +```bash +export ODBCSYSINI=integration-tests/generated +export ODBCINI=integration-tests/generated/odbc.ini +isql -3 test_sqlite -v +``` + +Prefix either with `ODBC_LOG_LEVEL=debug` to see which ODBC functions your +client calls, and in what order. diff --git a/integration-tests/generated/.gitignore b/integration-tests/generated/.gitignore new file mode 100644 index 0000000..eec6da5 --- /dev/null +++ b/integration-tests/generated/.gitignore @@ -0,0 +1,4 @@ +# Everything setup.sh writes here embeds absolute paths, so none of it is +# portable between checkouts. Keep the directory, ignore the contents. +* +!.gitignore diff --git a/integration-tests/run-tests.sh b/integration-tests/run-tests.sh new file mode 100755 index 0000000..308e84d --- /dev/null +++ b/integration-tests/run-tests.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Wrapper. The logic lives in scripts/run-tests.sh. +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/scripts/run-tests.sh" "$@" diff --git a/integration-tests/scripts/lib.sh b/integration-tests/scripts/lib.sh new file mode 100644 index 0000000..a0cd169 --- /dev/null +++ b/integration-tests/scripts/lib.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Shared paths and helpers. Sourced, never executed. +# +# SC2034: every variable below is consumed by a script that sources this file, +# which shellcheck cannot see from here. +# shellcheck disable=SC2034 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_DIR="$(cd "$TEST_DIR/.." && pwd)" + +SUITES_DIR="$TEST_DIR/suites" +WINDOWS_DIR="$TEST_DIR/windows" + +# Everything setup.sh writes lands here, and the whole directory is gitignored: +# all three files embed absolute paths, so none of them is portable between +# checkouts. +GENERATED="$TEST_DIR/generated" +DB_PATH="$GENERATED/test.db" +ODBC_INI="$GENERATED/odbc.ini" +ODBCINST_INI="$GENERATED/odbcinst.ini" + +DRIVER_PATH="$PROJECT_DIR/target/debug/libstackable_odbc_sqlite.so" + +# The DSN setup.sh writes into odbc.ini, and the one run-tests.sh connects +# through for its second configuration. +DSN_NAME="test_sqlite" + +mkdir -p "$GENERATED" + +# Point unixODBC at the generated configuration rather than the system's. +# ODBCSYSINI is a *directory* (unixODBC appends `odbcinst.ini` itself) while +# ODBCINI is a full path, which is why the two are not spelled alike. +use_generated_odbc_config() { + export ODBCSYSINI="$GENERATED" + export ODBCINI="$ODBC_INI" +} + +# Build the cdylib pyodbc loads. `cargo test` builds the test harness, not this, +# so a run that skips it would silently exercise the previous build. cargo is +# incremental, so repeating it costs nothing when nothing changed. +build_driver() { + echo "=== Building stackable-odbc-sqlite ===" + (cd "$PROJECT_DIR" && cargo build) +} + +# usage <script>. Prints the contiguous comment block below the shebang, minus +# the leading `# `, as that script's help text. Derived rather than given as a +# line range, which silently truncates the moment a line is added to the header. +usage() { + awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$1" +} + +require_setup() { + if [[ ! -f "$DB_PATH" || ! -f "$ODBC_INI" ]]; then + echo "ERROR: $GENERATED is incomplete. Run ./integration-tests/setup.sh first." >&2 + exit 1 + fi +} diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh new file mode 100755 index 0000000..f1675a2 --- /dev/null +++ b/integration-tests/scripts/run-tests.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Runs the pyodbc suite through real unixODBC, the Rust FFI tests, and +# optionally the same pyodbc suite inside a Windows VM. +# +# Requires setup.sh to have been run first. +# +# Usage: +# ./integration-tests/run-tests.sh # Linux only +# ./integration-tests/run-tests.sh --windows # Linux, then the Windows VM +# ./integration-tests/run-tests.sh --skip-build # reuse the driver already built +# ./integration-tests/run-tests.sh --skip-cargo-test # pyodbc only; what CI runs +# +# Any other argument is forwarded to windows_test.py (--host, --gateway, --user, +# --password), and is therefore only accepted alongside --windows. +set -euo pipefail + +# shellcheck source=integration-tests/scripts/lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +RUN_WINDOWS=false +SKIP_BUILD=false +SKIP_CARGO_TEST=false +WINDOWS_ARGS=() + +for arg in "$@"; do + case "$arg" in + --windows) RUN_WINDOWS=true ;; + # Forwarded as well as acted on: the VM build is a separate + # cross-compile, and skipping one without the other would be a surprise. + --skip-build) + SKIP_BUILD=true + WINDOWS_ARGS+=("$arg") + ;; + --skip-cargo-test) SKIP_CARGO_TEST=true ;; + -h | --help) + usage "${BASH_SOURCE[0]}" + exit 0 + ;; + *) WINDOWS_ARGS+=("$arg") ;; + esac +done + +# Silently forwarding to a script that is never invoked is how a typo'd flag +# becomes a test run that ignored it. +if [[ "$RUN_WINDOWS" == false && ${#WINDOWS_ARGS[@]} -gt 0 ]]; then + echo "ERROR: ${WINDOWS_ARGS[*]} only applies with --windows. Try --help." >&2 + exit 2 +fi + +require_setup + +if [[ "$SKIP_BUILD" == false ]]; then + build_driver +fi + +# Two configurations, matching what the Windows suite runs: a DSN-less +# connection string exercises the driver's own parsing, while the DSN goes +# through the Driver Manager's lookup first. +use_generated_odbc_config + +echo "=== Running Linux pyodbc integration tests (DSN-less) ===" +uv run --with pyodbc python3 "$SUITES_DIR/test_integration.py" \ + "Driver=$DRIVER_PATH;Database=$DB_PATH" + +echo "=== Running Linux pyodbc integration tests (DSN) ===" +uv run --with pyodbc python3 "$SUITES_DIR/test_integration.py" "DSN=$DSN_NAME" + +# Run by default so that a developer invoking this script gets the whole suite +# in one command. CI passes --skip-cargo-test, because its pre-commit job has +# already run exactly this via the cargo-test hook, and repeating it there means +# rebuilding the test harness on a second runner for no added coverage. +if [[ "$SKIP_CARGO_TEST" == false ]]; then + echo "=== Running SQLite FFI integration tests ===" + (cd "$PROJECT_DIR" && cargo test) +fi + +if [[ "$RUN_WINDOWS" == true ]]; then + echo "=== Running Windows VM integration tests ===" + uv run --with pywinrm python3 "$WINDOWS_DIR/windows_test.py" \ + "${WINDOWS_ARGS[@]+"${WINDOWS_ARGS[@]}"}" +fi diff --git a/integration-tests/scripts/setup.sh b/integration-tests/scripts/setup.sh new file mode 100755 index 0000000..669f694 --- /dev/null +++ b/integration-tests/scripts/setup.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Builds the driver, creates the test database, and writes the ODBC +# configuration the suites connect through. +# +# Usage: +# ./integration-tests/setup.sh # build, then (re)create everything +# ./integration-tests/setup.sh --skip-build # reuse the driver already built +set -euo pipefail + +# shellcheck source=integration-tests/scripts/lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +SKIP_BUILD=false +for arg in "$@"; do + case "$arg" in + --skip-build) SKIP_BUILD=true ;; + -h | --help) + usage "${BASH_SOURCE[0]}" + exit 0 + ;; + *) + echo "ERROR: unknown argument '$arg'. Try --help." >&2 + exit 2 + ;; + esac +done + +if [[ "$SKIP_BUILD" == false ]]; then + build_driver +fi + +if [[ ! -f "$DRIVER_PATH" ]]; then + echo "ERROR: $DRIVER_PATH does not exist. Drop --skip-build." >&2 + exit 1 +fi + +echo "=== Creating test database ===" +rm -f "$DB_PATH" +sqlite3 "$DB_PATH" < "$SUITES_DIR/create_test_db.sql" + +echo "=== Writing ODBC configuration ===" +cat > "$ODBCINST_INI" << EOF +[stackable_odbc_sqlite] +Driver = $DRIVER_PATH +EOF + +cat > "$ODBC_INI" << EOF +[$DSN_NAME] +Driver = stackable_odbc_sqlite +Database = $DB_PATH +EOF + +cat << EOF + +=== Setup complete === + +Run tests: ./integration-tests/run-tests.sh [--windows] + +To test interactively: + export ODBCSYSINI=$GENERATED + export ODBCINI=$ODBC_INI + isql -3 $DSN_NAME -v +EOF diff --git a/integration-tests/setup.sh b/integration-tests/setup.sh new file mode 100755 index 0000000..97cc00c --- /dev/null +++ b/integration-tests/setup.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Wrapper. The logic lives in scripts/setup.sh. +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/scripts/setup.sh" "$@" diff --git a/test/create_test_db.sql b/integration-tests/suites/create_test_db.sql similarity index 100% rename from test/create_test_db.sql rename to integration-tests/suites/create_test_db.sql diff --git a/test/test_integration.py b/integration-tests/suites/test_integration.py similarity index 98% rename from test/test_integration.py rename to integration-tests/suites/test_integration.py index 032f113..d36d700 100755 --- a/test/test_integration.py +++ b/integration-tests/suites/test_integration.py @@ -7,8 +7,8 @@ statements. Usage: - python3 test/test_integration.py "Driver=/path/to/driver.so;Database=/path/to/test.db" - python3 test/test_integration.py "Driver=C:\\path\\to\\driver.dll;Database=C:\\test.db" + python3 integration-tests/suites/test_integration.py "Driver=/path/to/driver.so;Database=/path/to/test.db" + python3 integration-tests/suites/test_integration.py "Driver=C:\\path\\to\\driver.dll;Database=C:\\test.db" Requires: pip install pyodbc """ diff --git a/windows/WINDOWS.md b/integration-tests/windows/WINDOWS.md similarity index 94% rename from windows/WINDOWS.md rename to integration-tests/windows/WINDOWS.md index 96ce2aa..5a035a6 100644 --- a/windows/WINDOWS.md +++ b/integration-tests/windows/WINDOWS.md @@ -14,24 +14,24 @@ Then run from the Linux host (`pywinrm` is installed automatically by `uv`). This runs the full integration suite twice: DSN-less, then via DSN. ```bash -uv run --with pywinrm python3 test/windows_test.py +uv run --with pywinrm python3 integration-tests/windows/windows_test.py ``` Common options: ```bash # Skip the cargo build (use an already-built DLL) -uv run --with pywinrm python3 test/windows_test.py --skip-build +uv run --with pywinrm python3 integration-tests/windows/windows_test.py --skip-build # Target a specific VM IP (skip DHCP lease discovery) -uv run --with pywinrm python3 test/windows_test.py --host 192.168.197.138 +uv run --with pywinrm python3 integration-tests/windows/windows_test.py --host 192.168.197.138 # Non-default libvirt subnet export ODBC_TEST_HOST_GATEWAY=10.0.0.1 # or: --gateway 10.0.0.1 # Full usage -uv run --with pywinrm python3 test/windows_test.py --help +uv run --with pywinrm python3 integration-tests/windows/windows_test.py --help ``` ### Using a different hypervisor (VirtualBox, Hyper-V, etc.) @@ -42,7 +42,7 @@ in a different hypervisor, the test script still works; just pass the VM's IP directly with `--host`: ```bash -uv run --with pywinrm python3 test/windows_test.py --host <vm-ip> +uv run --with pywinrm python3 integration-tests/windows/windows_test.py --host <vm-ip> ``` The VM must have WinRM enabled on port 5985 with NTLM auth, and Python 3 + @@ -53,7 +53,8 @@ they differ from the defaults. WinRM uses NTLM authentication, which requires MD4, disabled by default in modern OpenSSL. The test script automatically sets `OPENSSL_CONF` to point at -`windows/openssl_legacy.cnf`, which enables the legacy provider. +`integration-tests/windows/openssl_legacy.cnf`, which enables the legacy +provider. If you see `unsupported hash type md4` errors, check that the file exists and that you haven't overridden `OPENSSL_CONF` in your environment. @@ -101,7 +102,7 @@ pipx install uv # Download from: https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022 export WINDOWS_ISO=~/Downloads/SERVER_EVAL_x64FRE_en-us.iso -cd windows/vm +cd integration-tests/windows/vm nix-shell # loads Ansible + libvirt Python bindings ansible-playbook start.yaml -i inventory.ini ``` diff --git a/windows/openssl_legacy.cnf b/integration-tests/windows/openssl_legacy.cnf similarity index 100% rename from windows/openssl_legacy.cnf rename to integration-tests/windows/openssl_legacy.cnf diff --git a/windows/vm/files/windows-install-config/Autounattend.xml b/integration-tests/windows/vm/files/windows-install-config/Autounattend.xml similarity index 100% rename from windows/vm/files/windows-install-config/Autounattend.xml rename to integration-tests/windows/vm/files/windows-install-config/Autounattend.xml diff --git a/windows/vm/files/windows-install-config/redhat-drivers.crt b/integration-tests/windows/vm/files/windows-install-config/redhat-drivers.crt similarity index 100% rename from windows/vm/files/windows-install-config/redhat-drivers.crt rename to integration-tests/windows/vm/files/windows-install-config/redhat-drivers.crt diff --git a/windows/vm/inventory.ini b/integration-tests/windows/vm/inventory.ini similarity index 100% rename from windows/vm/inventory.ini rename to integration-tests/windows/vm/inventory.ini diff --git a/windows/vm/shell.nix b/integration-tests/windows/vm/shell.nix similarity index 100% rename from windows/vm/shell.nix rename to integration-tests/windows/vm/shell.nix diff --git a/windows/vm/start.yaml b/integration-tests/windows/vm/start.yaml similarity index 98% rename from windows/vm/start.yaml rename to integration-tests/windows/vm/start.yaml index 6002624..3eaf317 100644 --- a/windows/vm/start.yaml +++ b/integration-tests/windows/vm/start.yaml @@ -145,4 +145,4 @@ Windows VM ready at {{ vm_ip }}. Note: FirstLogonCommands (Python, pyodbc) may still be running. The test script waits for setup to complete automatically. - Run tests with: uv run --with pywinrm python3 test/sqlite/windows_test.py + Run tests with: uv run --with pywinrm python3 integration-tests/windows/windows_test.py diff --git a/windows/vm/templates/windows-vm-network-internet.xml.j2 b/integration-tests/windows/vm/templates/windows-vm-network-internet.xml.j2 similarity index 100% rename from windows/vm/templates/windows-vm-network-internet.xml.j2 rename to integration-tests/windows/vm/templates/windows-vm-network-internet.xml.j2 diff --git a/windows/vm/templates/windows-vm-network.xml.j2 b/integration-tests/windows/vm/templates/windows-vm-network.xml.j2 similarity index 100% rename from windows/vm/templates/windows-vm-network.xml.j2 rename to integration-tests/windows/vm/templates/windows-vm-network.xml.j2 diff --git a/windows/vm/templates/windows-vm-volume.xml.j2 b/integration-tests/windows/vm/templates/windows-vm-volume.xml.j2 similarity index 100% rename from windows/vm/templates/windows-vm-volume.xml.j2 rename to integration-tests/windows/vm/templates/windows-vm-volume.xml.j2 diff --git a/windows/vm/templates/windows-vm.xml.j2 b/integration-tests/windows/vm/templates/windows-vm.xml.j2 similarity index 100% rename from windows/vm/templates/windows-vm.xml.j2 rename to integration-tests/windows/vm/templates/windows-vm.xml.j2 diff --git a/test/windows_test.py b/integration-tests/windows/windows_test.py similarity index 94% rename from test/windows_test.py rename to integration-tests/windows/windows_test.py index 837d212..c0c7c57 100644 --- a/test/windows_test.py +++ b/integration-tests/windows/windows_test.py @@ -6,9 +6,9 @@ the driver, and runs test_integration.py through the Windows Driver Manager. Usage: - uv run --with pywinrm python3 test/windows_test.py - uv run --with pywinrm python3 test/windows_test.py --skip-build - uv run --with pywinrm python3 test/windows_test.py --host 192.168.197.138 + uv run --with pywinrm python3 integration-tests/windows/windows_test.py + uv run --with pywinrm python3 integration-tests/windows/windows_test.py --skip-build + uv run --with pywinrm python3 integration-tests/windows/windows_test.py --host 192.168.197.138 Requires: pywinrm (pip install pywinrm) """ @@ -25,9 +25,11 @@ import threading from pathlib import Path -SCRIPT_DIR = Path(__file__).resolve().parent -PROJECT_DIR = (SCRIPT_DIR / "..").resolve() -OPENSSL_CNF = PROJECT_DIR / "windows" / "openssl_legacy.cnf" +WINDOWS_DIR = Path(__file__).resolve().parent +TEST_DIR = WINDOWS_DIR.parent +PROJECT_DIR = TEST_DIR.parent +SUITES_DIR = TEST_DIR / "suites" +OPENSSL_CNF = WINDOWS_DIR / "openssl_legacy.cnf" REMOTE_DIR = r"C:\odbc_test" REMOTE_DLL = rf"{REMOTE_DIR}\stackable_odbc_sqlite.dll" @@ -57,7 +59,7 @@ def main(): build_dll(args.target) dll_path = resolve_dll_path(args.target) - test_path = SCRIPT_DIR / "test_integration.py" + test_path = SUITES_DIR / "test_integration.py" host = args.host or discover_vm_ip(args.vm_network) @@ -185,7 +187,7 @@ def setup_openssl(): print( f"WARNING: {OPENSSL_CNF} not found. WinRM NTLM auth may fail\n" "if your OpenSSL does not have the legacy provider enabled.\n" - "See windows/WINDOWS.md for details.", + "See integration-tests/windows/WINDOWS.md for details.", file=sys.stderr, ) @@ -260,7 +262,7 @@ def discover_vm_ip(network: str) -> str: except subprocess.CalledProcessError as e: print( f"ERROR: could not query DHCP leases for network '{network}'.\n" - "Is the VM running? See windows/WINDOWS.md for setup.\n" + "Is the VM running? See integration-tests/windows/WINDOWS.md for setup.\n" f"virsh output: {e.stderr}", file=sys.stderr, ) @@ -271,7 +273,7 @@ def discover_vm_ip(network: str) -> str: if not ips: print( f"ERROR: no DHCP leases found on network '{network}'.\n" - "Is the VM running? See windows/WINDOWS.md for setup.", + "Is the VM running? See integration-tests/windows/WINDOWS.md for setup.", file=sys.stderr, ) sys.exit(1) diff --git a/test/.gitignore b/test/.gitignore deleted file mode 100644 index 4dcdabd..0000000 --- a/test/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -test.db -odbcinst.ini -odbc.ini diff --git a/test/run-tests.sh b/test/run-tests.sh deleted file mode 100755 index f6ecb58..0000000 --- a/test/run-tests.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Runs SQLite integration tests (Linux) and optionally Windows VM tests. -# -# Requires setup.sh to have been run first. -# -# Usage: -# ./test/run-tests.sh # Linux tests only -# ./test/run-tests.sh --windows # Linux + Windows VM tests -# ./test/run-tests.sh --skip-build # skip the cargo build (also passed to windows_test.py) -# ./test/run-tests.sh --skip-cargo-test # skip `cargo test` (CI already runs it via pre-commit) -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -DRIVER_PATH="$PROJECT_DIR/target/debug/libstackable_odbc_sqlite.so" -DB_PATH="$SCRIPT_DIR/test.db" - -RUN_WINDOWS=false -SKIP_BUILD=false -SKIP_CARGO_TEST=false -WINDOWS_EXTRA_ARGS=() - -for arg in "$@"; do - case "$arg" in - --windows) RUN_WINDOWS=true ;; - --skip-build) SKIP_BUILD=true; WINDOWS_EXTRA_ARGS+=("$arg") ;; - --skip-cargo-test) SKIP_CARGO_TEST=true ;; - *) WINDOWS_EXTRA_ARGS+=("$arg") ;; - esac -done - -# --- Rebuild the driver --- -# Only setup.sh built the .so, so editing driver source and re-running this -# script would silently test the previous build. `cargo test` below builds the -# test harness, not the cdylib that pyodbc loads, so an explicit build is -# needed. cargo is incremental, so this is a no-op when nothing changed. -if [[ "$SKIP_BUILD" == false ]]; then - echo "=== Building stackable-odbc-sqlite ===" - (cd "$PROJECT_DIR" && cargo build) -fi - -# --- Linux: pyodbc integration tests (2 configs, matching Windows) --- -export ODBCSYSINI="$SCRIPT_DIR" -export ODBCINI="$SCRIPT_DIR/odbc.ini" - -echo "=== Running Linux pyodbc integration tests (DSN-less) ===" -uv run --with pyodbc python3 "$SCRIPT_DIR/test_integration.py" \ - "Driver=$DRIVER_PATH;Database=$DB_PATH" - -echo "=== Running Linux pyodbc integration tests (DSN) ===" -uv run --with pyodbc python3 "$SCRIPT_DIR/test_integration.py" "DSN=test_sqlite" - -# --- Linux: Rust FFI integration tests --- -# Run by default so that a developer invoking this script gets the whole suite -# in one command. CI passes --skip-cargo-test, because its pre-commit job has -# already run exactly this via the cargo-test hook, and repeating it there -# means rebuilding the test harness on a second runner for no added coverage. -if [[ "$SKIP_CARGO_TEST" == false ]]; then - echo "=== Running SQLite FFI integration tests ===" - (cd "$PROJECT_DIR" && cargo test) -fi - -# --- Windows VM tests (optional) --- -if [[ "$RUN_WINDOWS" == true ]]; then - echo "=== Running Windows VM integration tests ===" - uv run --with pywinrm python3 "$SCRIPT_DIR/windows_test.py" "${WINDOWS_EXTRA_ARGS[@]+"${WINDOWS_EXTRA_ARGS[@]}"}" -fi diff --git a/test/setup.sh b/test/setup.sh deleted file mode 100755 index cbdfba8..0000000 --- a/test/setup.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TEST_DIR="$SCRIPT_DIR" - -echo "=== Building stackable-odbc-sqlite ===" -cd "$PROJECT_DIR" -cargo build - -DRIVER_PATH="$PROJECT_DIR/target/debug/libstackable_odbc_sqlite.so" -DB_PATH="$TEST_DIR/test.db" - -echo "=== Creating test database ===" -rm -f "$DB_PATH" -sqlite3 "$DB_PATH" < "$TEST_DIR/create_test_db.sql" - -echo "=== Writing ODBC configuration ===" -cat > "$TEST_DIR/odbcinst.ini" << EOF -[stackable_odbc_sqlite] -Driver = $DRIVER_PATH -EOF - -cat > "$TEST_DIR/odbc.ini" << EOF -[test_sqlite] -Driver = stackable_odbc_sqlite -Database = $DB_PATH -EOF - -echo "" -echo "=== Setup complete ===" -echo "" -echo "Run tests: ./test/run-tests.sh [--windows]" -echo "" -echo "To test interactively:" -echo " export ODBCSYSINI=$TEST_DIR" -echo " export ODBCINI=$TEST_DIR/odbc.ini" -echo " isql -3 test_sqlite -v" From ec5102f4eab2837890db630183f1b57b05bd2bee Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 14:27:38 +0200 Subject: [PATCH 31/50] feat: adopt core's setup hook, and bring packaging up to the Trino driver's level Four changes that arrived together, kept in one commit because the packaging half is what makes the driver half reachable. **The setup dialog.** Core's Backend::configure_dsn was defaulted to the identity function, so this driver wrote a data source with no Database key whenever the ODBC Administrator's Add... button was pressed. Core owns all of ConfigDSN -- validating the request, merging the data source's stored keywords in, calling SQLValidDSN and writing through SQLWriteDSNToIni. src/backend/setup.rs supplies only the dialog, and the dialog itself is packaging/windows/configure-dsn.ps1 run with -Emit, which prints the keywords it collected instead of writing them. Reusing the script keeps one list of keywords: its $Fields table names them, and dsn_keys_match_the_connection_string_parser fails the build if that table and the parser disagree. Only the two kernel32 calls are cfg(windows); every decision is a plain function unit-tested on Linux. The dialog is a fifth of the Trino driver's, because SQLite has one connection-string keyword. Its Test button reports the SQLite version *and the table count*, which is the check worth having here: SQLite creates a missing file rather than refusing, so a typo in the path connects perfectly well and finds nothing. **The version resource.** build.rs embeds a Windows VERSIONINFO resource, without which the Administrator lists the driver as "Not marked" under Version and Company, as it does for every Rust cdylib. Every string comes from Cargo.toml through cargo's own environment, so the resource cannot disagree with the package. **Core from git.** Taken from the repository rather than a sibling checkout, so a clean clone and CI can build without one. deny.toml allows that one repository by name; CONTRIBUTING.md documents the [patch] override for working on core at the same time, which is the common case here. **The SBOM pipeline.** Both archives now carry a CycloneDX SBOM generated from the dependency list cargo-auditable embeds in the binary rather than from Cargo.toml, so it describes what was linked. sbom-native.json declares the two components cargo cannot see, verified against the real binaries by `sbom.sh --check-native` on every pull request: SQLite itself, which cargo sees only as the libsqlite3-sys wrapper, and what each artifact links at load time. The release also carries SPDX, sha256sums.txt and build-provenance attestations. CI gains a Windows unit-test job -- the only one that compiles backend::setup's cfg(windows) half -- a release-artifacts job running the native checks, a Scorecard workflow, and a `finished` gate derived from needs.* rather than a hand-written list. Enabling --document-private-items on the cargo-doc hook surfaced two intra-doc links that had been broken and unchecked; both are fixed here. Verified on the Windows VM against the real Administrator: Add... opens the dialog with an editable name and no scope radios, Test connection reports SQLite 3.53.2 and the fixture's three tables, OK writes the data source, Configure... prefills it and fixes the name as the spec requires, Cancel leaves it untouched, Remove takes it without prompting and without touching the database file, and -NoGui writes headlessly. The Drivers tab lists the driver as 0.00.01.00 / Stackable GmbH. The 46 integration tests through the Windows Driver Manager still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/build.yaml | 131 +++- .github/workflows/release.yaml | 132 +++- .github/workflows/scorecard.yaml | 49 ++ .github/workflows/security_audit.yaml | 5 +- .gitignore | 3 + .pre-commit-config.yaml | 16 +- CHANGELOG.md | 45 ++ CONTRIBUTING.md | 152 +++++ Cargo.lock | 2 + Cargo.toml | 6 +- README.md | 41 +- SECURITY.md | 38 ++ build.rs | 201 ++++++ deny.toml | 3 + integration-tests/windows/windows_test.py | 13 +- packaging/README.md | 192 ++++-- packaging/build-archives.sh | 55 +- packaging/sbom-native.json | 93 +++ packaging/sbom.sh | 241 +++++++ packaging/test-sbom.sh | 187 ++++++ packaging/windows/configure-dsn.ps1 | 743 ++++++++++++++++++++++ packaging/windows/install.bat | 21 +- packaging/windows/uninstall.bat | 3 + renovate.json | 6 + src/backend.rs | 17 + src/backend/execute.rs | 3 +- src/backend/info.rs | 2 +- src/backend/setup.rs | 432 +++++++++++++ src/lib.rs | 132 ++++ 29 files changed, 2836 insertions(+), 128 deletions(-) create mode 100644 .github/workflows/scorecard.yaml create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 build.rs create mode 100644 packaging/sbom-native.json create mode 100755 packaging/sbom.sh create mode 100755 packaging/test-sbom.sh create mode 100644 packaging/windows/configure-dsn.ps1 create mode 100644 renovate.json create mode 100644 src/backend/setup.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2a9b81d..30c5f91 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -21,6 +21,10 @@ env: CARGO_TERM_COLOR: always RUST_TOOLCHAIN_VERSION: "1.95.0" +# Every job below names a runner image rather than a `-latest` alias, so an +# image roll cannot change what a merge is gated on. The label cannot be lifted +# into a variable: `runs-on` accepts no `env` context, and the one context that +# would work, `vars`, holds its value in repository settings rather than here. jobs: # The whole gate: `cargo test`, formatting, clippy (which is what enforces the # unwrap_used / unwrap_in_result / panic denies from Cargo.toml), rustdoc, @@ -34,7 +38,7 @@ jobs: # pass, which is only true if CI runs the same thing. pre-commit: name: pre-commit - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: # The cargo-test pre-commit hook links libodbc via odbc-sys. @@ -42,14 +46,16 @@ jobs: uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 with: packages: unixodbc-dev - version: ubuntu-latest + # A cache key, not a runner label, but it tracks the runner image so + # that bumping the image invalidates the cached .deb files. + version: ubuntu-24.04 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.95.0 with: toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} components: rustfmt, clippy @@ -70,10 +76,11 @@ jobs: # This suite needs only unixODBC and the sqlite3 CLI, no server and no # container, so it runs on a standard runner in seconds and is worth - # gating every pull request on. + # gating every pull request on. The Trino driver cannot do this, which is + # part of why this driver exists. sqlite-integration: name: SQLite Integration Tests - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 needs: [pre-commit] steps: @@ -81,12 +88,11 @@ jobs: uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 with: packages: unixodbc-dev unixodbc sqlite3 - version: ubuntu-latest + version: ubuntu-24.04 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b @@ -105,19 +111,65 @@ jobs: # --skip-cargo-test: the pre-commit job above already ran it. ./integration-tests/run-tests.sh --skip-cargo-test - windows-cross-compile: - name: Cross-compile Windows DLL - runs-on: ubuntu-latest + unit-tests-windows: + name: Unit Tests (Windows) + runs-on: windows-2022 + timeout-minutes: 30 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + targets: x86_64-pc-windows-gnu + + # A separate cache key: the Linux job's artefacts are a different target + # triple and sharing the key would thrash both. + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: windows-gnu-test + + # The GNU target's linker, and the C compiler libsqlite3-sys needs to + # build the bundled SQLite amalgamation. The runner image ships MSYS2, + # but its mingw64 bin directory is not on PATH by default. + - name: Add MinGW to PATH + run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + # `--target x86_64-pc-windows-gnu`, not the runner's default MSVC triple. + # That is the target release.yaml builds and packaging/build-archives.sh + # ships, and a suite passing against a toolchain nobody receives is only + # evidence about that toolchain. odbc-sys links odbc32, which comes with + # the Windows SDK already on the runner, so there is no equivalent of the + # unixodbc-dev install the Linux jobs need. + # + # This is also the only job that compiles `backend::setup`'s + # `#[cfg(windows)]` module, which is the half of the setup dialog that + # calls into kernel32. + - name: Run unit tests + run: cargo test --locked --target x86_64-pc-windows-gnu + + # Builds both shipping artifacts the way release.yaml does, and checks the two + # properties that are invisible in a unit test run: that the DLL exports the + # ODBC entry points, and that what each artifact links at load time still + # matches packaging/sbom-native.json. The SBOM declares native dependencies by + # hand, since no cargo metadata describes them, so nothing but this check keeps + # the declaration true. + release-artifacts: + name: Release Artifacts + runs-on: ubuntu-24.04 timeout-minutes: 20 needs: [pre-commit] steps: - name: Install MinGW cross-compiler - run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 + run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 unixodbc-dev - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b @@ -130,13 +182,40 @@ jobs: with: key: windows-gnu + - name: Build Linux shared library + run: cargo build --locked --release + - name: Build Windows DLL - run: cargo build --target x86_64-pc-windows-gnu --release + run: cargo build --locked --target x86_64-pc-windows-gnu --release - name: Verify DLL exports run: | x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll | grep -c "SQL" | xargs -I{} echo "SQLite DLL: {} ODBC symbols exported" + # build.rs embeds this, and it is what stops the ODBC Data Source + # Administrator listing the driver as "Not marked". A cross-build with no + # windres on PATH fails loudly, but a change to build.rs that silently + # stops emitting the resource would not, so the section is asserted here. + - name: Verify the DLL carries a version resource + run: | + x86_64-w64-mingw32-objdump -h target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll \ + | grep -q '\.rsrc' || { echo "::error::the DLL carries no .rsrc section"; exit 1; } + echo "Version resource present." + + # Two different assertions behind one flag. For the .so it compares + # DT_NEEDED against the sonames sbom-native.json declares, in both + # directions. For the .dll it asserts the mingw runtime is still linked + # statically: the release archive ships no runtime DLL, so an artifact + # that imported one would fail to load on a user's machine. + # + # Only the release binaries are checked, and only here rather than in + # release.yaml, because a pull request is where a dependency change can + # still be reverted cheaply. + - name: Verify declared native dependencies + run: | + ./packaging/sbom.sh --check-native target/release/libstackable_odbc_sqlite.so + ./packaging/sbom.sh --check-native target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll + # Single required check for branch protection rules. finished: name: Finished Build and Test @@ -144,16 +223,22 @@ jobs: needs: - pre-commit - sqlite-integration - - windows-cross-compile - runs-on: ubuntu-latest - timeout-minutes: 10 + - unit-tests-windows + - release-artifacts + runs-on: ubuntu-24.04 + timeout-minutes: 5 steps: + # Derived from needs.* rather than a hand-written list of job names: a job + # added to `needs` above but forgotten here would otherwise be silently + # non-blocking. - name: Check job results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} run: | - if [[ "${{ needs.pre-commit.result }}" != "success" ]] || - [[ "${{ needs.sqlite-integration.result }}" != "success" ]] || - [[ "${{ needs.windows-cross-compile.result }}" != "success" ]]; then - echo "One or more jobs failed" - exit 1 - fi - echo "All jobs passed" + for result in $RESULTS; do + if [[ "$result" != "success" ]]; then + echo "One or more jobs did not succeed: $RESULTS" + exit 1 + fi + done + echo "All jobs passed: $RESULTS" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4b10c1f..9ac04e4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,27 +4,38 @@ name: Release on: push: tags: - - 'v*' + # The tag format cargo-release produces; see release.toml. + - "v*" workflow_dispatch: +# Read at the top level; the two jobs that need more grant it to themselves. +# Attestation needs an OIDC token, and only the publishing job writes. permissions: - contents: write + contents: read env: CARGO_TERM_COLOR: always RUST_TOOLCHAIN_VERSION: "1.95.0" + # Both pinned rather than floating. packaging/test-sbom.sh asserts the shape of + # what syft emits and how cargo-auditable's .dep-v0 section reads, so a release + # of either that changes that shape has to be adopted deliberately and + # re-verified, not picked up silently on the next tag push. + SYFT_VERSION: "v1.50.0" + CARGO_AUDITABLE_VERSION: "0.7.5" +# Every job below names a runner image rather than a `-latest` alias, so an +# image roll cannot change what a tagged release is built against. See +# build.yaml for why the label is repeated rather than named once. jobs: verify-version: name: Verify tag matches Cargo.toml - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: version: ${{ steps.extract.outputs.version }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} - id: extract name: Compare tag and Cargo.toml version @@ -39,21 +50,22 @@ jobs: echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" echo "Verified: releasing stackable-odbc-sqlite $CARGO_VERSION" + # The suite is server-free, so unlike the Trino driver's it can gate a + # release as well as a pull request. integration-test: name: SQLite Integration Tests - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [verify-version] steps: - name: Install host dependencies uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 with: packages: unixodbc-dev unixodbc sqlite3 - version: ubuntu-latest + version: ubuntu-24.04 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b @@ -72,9 +84,16 @@ jobs: ./integration-tests/run-tests.sh build-and-package: - name: Build and package SQLite release archives - runs-on: ubuntu-latest + name: Build and package release archives + runs-on: ubuntu-24.04 needs: [verify-version, integration-test] + # id-token and attestations are what actions/attest-* exchange for a + # Sigstore signing certificate; contents stays read, since this job + # publishes nothing. + permissions: + contents: read + id-token: write + attestations: write steps: - name: Install host dependencies run: | @@ -84,7 +103,6 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b @@ -95,13 +113,33 @@ jobs: - name: Setup Rust Cache uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 with: - key: release-sqlite + key: release + # packaging/sbom.sh reads the .dep-v0 section cargo-auditable embeds, and + # refuses an artifact without one. Both tools are therefore preconditions + # of packaging, not optional extras. + - name: Install cargo-auditable + uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4 + with: + tool: cargo-auditable@${{ env.CARGO_AUDITABLE_VERSION }} + + - name: Install syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: ${{ env.SYFT_VERSION }} + + # `cargo auditable build`, not `cargo build`: the plain form links the same + # code but embeds no dependency graph, and the SBOM then lists a handful of + # components instead of the whole tree. + # + # --locked so the released binary is built from the versions Cargo.lock + # pins. The SBOM describes what was linked, so an unlocked build would + # produce an accurate document about an unintended dependency set. - name: Build Linux release binary - run: cargo build --release + run: cargo auditable build --locked --release - name: Build Windows release binary (cross) - run: cargo build --release --target x86_64-pc-windows-gnu + run: cargo auditable build --locked --release --target x86_64-pc-windows-gnu - name: Assemble release archives env: @@ -118,34 +156,80 @@ jobs: echo "--- Linux archive ---" tar -tzf "$LINUX" - for f in libstackable_odbc_sqlite.so install.sh uninstall.sh README.md LICENSE; do + for f in libstackable_odbc_sqlite.so libstackable_odbc_sqlite.so.cdx.json \ + install.sh uninstall.sh README.md LICENSE; do tar -tzf "$LINUX" | grep -qx "./$f" || { echo "::error::missing $f in linux archive"; exit 1; } done echo "--- Windows archive ---" unzip -l "$WINDOWS" - for f in stackable_odbc_sqlite.dll install.bat uninstall.bat README.md LICENSE; do + # configure-dsn.ps1 is load-bearing rather than an extra: install.bat + # refuses to register the driver without it. + for f in stackable_odbc_sqlite.dll stackable_odbc_sqlite.dll.cdx.json \ + configure-dsn.ps1 install.bat uninstall.bat README.md LICENSE; do unzip -l "$WINDOWS" | grep -q " $f\$" || { echo "::error::missing $f in windows archive"; exit 1; } done + echo "--- Checksums ---" + (cd "$DIST" && sha256sum -c sha256sums.txt) + echo "Archive sanity check passed." + # TODO(@maltesander): The published binaries are unsigned. Authenticode + # for stackable_odbc_sqlite.dll needs a code-signing + # certificate, which has not been bought. Until then + # Windows SmartScreen warns on the installer. The + # attestations below are a different guarantee: they + # prove where an artifact was built, not who vouches + # for it, and no operating system consults them. + + # Signs a statement that these files came out of this workflow, at this + # commit, and records it in the public transparency log. Verified with + # `gh attestation verify <file> --repo stackabletech/stackable-odbc-sqlite`. + # sha256sums.txt is included so the SBOM assets, which it covers, are + # reachable from an attested file. + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + packaging/dist/*.tar.gz + packaging/dist/*.zip + packaging/dist/sha256sums.txt + + # One call per artifact, because each binds exactly one SBOM to one + # subject. The CycloneDX document is the one attested; the SPDX one beside + # it is a conversion of the same data for consumers that need that format. + - name: Attest SBOM for the Linux archive + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: packaging/dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-linux-x64.tar.gz + sbom-path: packaging/dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-linux-x64.cdx.json + + - name: Attest SBOM for the Windows archive + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: packaging/dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-windows-x64.zip + sbom-path: packaging/dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-windows-x64.cdx.json + - name: Upload archives as workflow artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: sqlite-release-archives + name: release-archives path: packaging/dist/* retention-days: 7 publish-release: name: Publish GitHub Release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [verify-version, build-and-package] + # The only job that writes, and it writes exactly one thing: the release. + permissions: + contents: write steps: - name: Download archives uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: sqlite-release-archives + name: release-archives path: dist - name: Determine prerelease flag @@ -163,9 +247,19 @@ jobs: uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 with: tag_name: ${{ github.ref_name }} - name: SQLite driver ${{ needs.verify-version.outputs.version }} + name: stackable-odbc-sqlite ${{ needs.verify-version.outputs.version }} generate_release_notes: true prerelease: ${{ steps.prerelease.outputs.flag }} + # Each archive already carries its own CycloneDX SBOM, so an offline + # install has one. The four standalone documents are here for whoever + # needs to read an SBOM without downloading and unpacking a release, + # and in SPDX as well as CycloneDX because tools take one or the other. + # sha256sums.txt covers every file listed above it. files: | dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-linux-x64.tar.gz dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-windows-x64.zip + dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-linux-x64.cdx.json + dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-linux-x64.spdx.json + dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-windows-x64.cdx.json + dist/stackable-odbc-sqlite-${{ needs.verify-version.outputs.version }}-windows-x64.spdx.json + dist/sha256sums.txt diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml new file mode 100644 index 0000000..01c3cca --- /dev/null +++ b/.github/workflows/scorecard.yaml @@ -0,0 +1,49 @@ +--- +name: OpenSSF Scorecard + +# Scorecard grades repository configuration rather than the crate. +# It scores pinned action SHAs, workflow permissions and release +# provenance, which makes it a regression check on the supply-chain work. +# +# `publish_results` and the SARIF upload both require a public repository. +# Runs before this one goes public are expected to fail. + +on: + branch_protection_rule: + schedule: + # Every Monday at 05:30 UTC: https://crontab.guru/#30_5_*_*_1 + - cron: '30 5 * * 1' + push: + branches: + - main + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-24.04 + permissions: + # Upload the results to the code-scanning dashboard. + security-events: write + # Publish results to the public Scorecard API, which backs the badge. + id-token: write + contents: read + actions: read + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload results to code scanning + uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3 # v3.37.4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/security_audit.yaml b/.github/workflows/security_audit.yaml index 3f04918..ef7afc1 100644 --- a/.github/workflows/security_audit.yaml +++ b/.github/workflows/security_audit.yaml @@ -12,13 +12,14 @@ permissions: jobs: audit: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - token: ${{ secrets.GITHUB_TOKEN }} + # This `token` is the action's own input, not checkout's: audit-check + # needs it to post the advisory annotations onto the run. - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5beef67..5b9df4f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ packaging/dist/ # Python bytecode from the test scripts __pycache__/ *.pyc + +# Local cargo overrides, e.g. a [patch] pointing core at a sibling checkout. +.cargo/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6700cf4..947dfdd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,13 +32,19 @@ repos: - repo: local hooks: + # The packaging files are in `files:` because tests in src/lib.rs + # `include_str!` them: the Windows dialog is checked against the + # connection-string parser, and sbom-native.json's SQLite version against + # the library that is actually linked. Changing one of those without + # touching any .rs file is exactly the case those tests exist to catch, + # and without this the hook would not run for it. - id: cargo-test name: cargo-test language: system entry: cargo test --locked stages: [pre-commit, pre-merge-commit] pass_filenames: false - files: \.rs$|Cargo\.(toml|lock) + files: \.rs$|Cargo\.(toml|lock)|^packaging/(sbom-native\.json|windows/(configure-dsn\.ps1|install\.bat))$ - id: cargo-rustfmt name: cargo-rustfmt @@ -59,10 +65,16 @@ repos: # Broken intra-doc links are warnings, not errors, so they reach a # published doc build silently. -D warnings promotes them. Runs in well # under a second because the dependency graph is already built above. + # + # --document-private-items, because almost everything in this crate is + # private -- `backend::setup` entirely so -- and without it their doc + # comments are checked by nothing. Not `--all-targets`: that builds the + # test target, sets `cfg(test)`, and would pull the `#[cfg(test)]` + # modules into the check. - id: cargo-doc name: cargo-doc language: system - entry: env RUSTDOCFLAGS=-Dwarnings cargo doc --locked --no-deps + entry: env RUSTDOCFLAGS=-Dwarnings cargo doc --locked --no-deps --document-private-items stages: [pre-commit, pre-merge-commit] pass_filenames: false files: \.rs$|Cargo\.(toml|lock) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3f035..d4f8849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A setup dialog on Windows.** The ODBC Data Source Administrator's **Add…** + and **Configure…** buttons now display a dialog instead of silently writing a + data source with no `Database` key. It asks for the data source name and the + database file, offers a file browser, and has a **Test connection** button + that opens the file and reports the SQLite version and how many tables it + found — which is the check worth having, because SQLite creates a missing + file rather than refusing, so a typo in the path connects perfectly well and + finds nothing. + + This is `stackable-odbc-core`'s new `Backend::configure_dsn` hook: core owns + all of `ConfigDSN` — validating the request, merging the data source's stored + keywords in, and writing through `SQLWriteDSNToIni` — and this driver + supplies only the dialog. The dialog itself is + `packaging/windows/configure-dsn.ps1`, which also runs standalone for a + scripted install (`-NoGui -Set @{...}`). `install.bat` installs it beside the + DLL and refuses to register the driver without it. + + Cancelling the dialog leaves the data source untouched and posts no error. + A **Remove** never prompts: the Administrator has already confirmed it, and + removing a data source does not touch the database file it points at. + +- **The Windows DLL carries a version resource.** The ODBC Data Source + Administrator listed the driver as `Not marked` under Version and Company, + because no Rust `cdylib` emits one. `build.rs` now generates it with + `windres`, taking every string from `Cargo.toml` through cargo's own + environment, so it cannot disagree with the package. + +- **Every release archive carries an SBOM.** One CycloneDX and one SPDX + document per artifact, generated from the dependency list `cargo auditable` + embeds in the binary rather than from `Cargo.toml`, so it describes what was + linked: dev-dependencies are excluded by construction, and a git dependency's + purl names the resolved commit rather than a branch that moves. + + Two components cargo cannot see are declared by hand in + `packaging/sbom-native.json` and verified against the real binaries by + `packaging/sbom.sh --check-native`, which CI runs on every pull request: the + bundled SQLite itself, which cargo sees only as the `libsqlite3-sys` wrapper, + and what each artifact links at load time. The release page also carries + `sha256sums.txt` and build-provenance attestations. + - `SQLCancel` actually cancels. A statement running on one thread can be stopped from another, which is the case the spec singles out: the driver now holds `sqlite3_interrupt`'s handle for the connection and calls it, so the @@ -70,6 +110,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `stackable-odbc-core` is taken from git rather than from a sibling checkout, + so a clean clone and CI can build this driver without one. To work on core at + the same time, put a `[patch]` in your own `.cargo/config.toml`; see + [`CONTRIBUTING.md`](CONTRIBUTING.md). + - `SQL_QUOTED_IDENTIFIER_CASE` reports `SQL_IC_MIXED` instead of `SQL_IC_SENSITIVE`. In SQLite, double quotes are a *delimiter* — they let a keyword or a name with punctuation be used as an identifier — and do not diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9e7c599 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ +# Contributing + +Thanks for considering a contribution. Bug reports, connection strings that +fail, and reports of a tool that will not talk to the driver are all useful. + +This driver exists to exercise +[`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core) on +a backend that needs no server, so a change that makes it a better test of core +is as welcome as one that makes it a better SQLite driver. + +- **Questions and ideas:** [GitHub Discussions](https://github.com/orgs/stackabletech/discussions) + or [Discord](https://discord.gg/7kZ3BNnCAF). +- **Bugs:** open an issue. Please say which platform, which Driver Manager + (unixODBC or the Windows one), and which application. A driver log helps most + of all: set `ODBC_LOG_FILE` and `ODBC_LOG_LEVEL=debug` and attach the result. +- **Security problems:** do not open an issue. See [SECURITY.md](SECURITY.md). + +## Building + +You need the unixODBC development libraries, because the ODBC bindings link +against them. SQLite itself is compiled into the driver, so there is nothing +else to install, and no database or ODBC configuration is needed to build and +run the unit tests. + +```bash +sudo apt-get install unixodbc-dev # Debian/Ubuntu +sudo pacman -S unixodbc # Arch +``` + +```bash +git clone https://github.com/stackabletech/stackable-odbc-sqlite +cd stackable-odbc-sqlite +cargo build --release +``` + +That produces `target/release/libstackable_odbc_sqlite.so`. + +Everything generic about being an ODBC driver lives in +[`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core): +handle management, UTF-16 marshalling, diagnostics, panic safety and the +exported C entry points. This repository holds only the SQLite-specific half. +Cargo fetches core for you, so there is nothing to clone by hand. + +### Working on core at the same time + +This is the common case here, because this driver is where core's changes are +tried out. To build against a local checkout of core rather than the fetched +one, add a `[patch]` to your own `.cargo/config.toml`, which is not checked in: + +```toml +[patch."https://github.com/stackabletech/stackable-odbc-core.git"] +stackable-odbc-core = { path = "../stackable-odbc-core" } +``` + +`.cargo/` is gitignored, so the override cannot be committed. **`Cargo.lock` +can**: cargo rewrites core's entry to the local path while the patch is active, +so check `git status` before committing. Remove the override, or push your core +changes, before you rely on a build. + +The toolchain version is pinned in `rust-toolchain.toml`, so rustup will fetch +the right one on first build. + +### Windows + +Cross-compile with MinGW (`gcc-mingw-w64-x86-64`): + +```bash +rustup target add x86_64-pc-windows-gnu +cargo build --release --target x86_64-pc-windows-gnu +``` + +That produces `target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll`. +`build.rs` embeds a version resource into it with `windres`, which comes with +that same package; a build without it fails rather than shipping a DLL the ODBC +Data Source Administrator lists as `Not marked`. + +Anything destined for a release archive is built with +[`cargo auditable`](https://github.com/rust-secure-code/cargo-auditable), which +embeds the dependency list the SBOM is generated from. See +[`packaging/README.md`](packaging/README.md). + +## Testing + +```bash +cargo test # unit and FFI tests; no setup needed +cargo clippy --all-targets -- -D warnings +``` + +`cargo test` must produce zero warnings. It drives the real exported C entry +points against real handles, so it catches marshalling bugs an ordinary Rust +test cannot. + +The integration suite goes one layer further out, through real unixODBC using +Python's `pyodbc`. It needs no server, so unlike the Trino driver's suite it +runs on every pull request: + +```bash +./integration-tests/setup.sh # build the driver, create the database, write the ODBC config +./integration-tests/run-tests.sh # run the pyodbc suite, then cargo test +``` + +See [`integration-tests/README.md`](integration-tests/README.md) for the flags. +The Windows suite runs the same tests through the Windows Driver Manager in a +VM; see +[`integration-tests/windows/WINDOWS.md`](integration-tests/windows/WINDOWS.md). + +## Before you commit + +```bash +pre-commit run --all-files +``` + +That is the gate, and it is the single source of truth for what must pass. It +runs rustfmt, clippy, `cargo test`, rustdoc, cargo-deny, cargo-sort, shellcheck +and markdownlint. + +Two more things a change usually needs: + +- **A changelog entry**, under `## [Unreleased]` in + [`CHANGELOG.md`](CHANGELOG.md), if an ODBC application can observe the + difference. A changed SQLSTATE, a changed `SQLGetInfo` value, a new + connection-string key or a different type mapping all count. +- **A new connection-string key means three edits**: the parser in + `src/backend/types/connect_params.rs`, the table in [`README.md`](README.md), + and the `$Fields` table in `packaging/windows/configure-dsn.ps1`. + `dsn_keys_match_the_connection_string_parser` in `src/lib.rs` fails the build + if the parser and the dialog disagree. + +## Where things live + +[`AGENTS.md`](AGENTS.md) is the working reference: module layout, the split +against `stackable-odbc-core`, the error-mapping rules, and the measured SQLite +and Driver Manager behaviour behind the design decisions. Read the section that +covers whatever you are about to change. It is written for AI coding agents and +human contributors alike. + +Two rules are worth stating here, because they are the ones most easily broken +by a reasonable-looking change: + +- **Read the ODBC spec page for any function whose behaviour you change.** What + the driver returns from `SQLGetInfo`, from the catalog functions and from the + type-conversion paths is directly observable by applications, and each has a + spec-defined shape and value range. +- **Route every client error through `map_sqlite_error`.** It is the single + place that decides the SQLSTATE and carries SQLite's own extended result code + through to `SQLGetDiagRec`. Building an error at the call site quietly + degrades it. + +## License + +By contributing you agree that your contribution is licensed under +[Apache-2.0](LICENSE). diff --git a/Cargo.lock b/Cargo.lock index 083f7e2..6fa56fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,6 +859,7 @@ dependencies = [ [[package]] name = "stackable-odbc-core" version = "0.0.1" +source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#5bc04176095b0881c3cd5620d9546c3f593ef96c" dependencies = [ "odbc-sys", "snafu", @@ -874,6 +875,7 @@ dependencies = [ "criterion", "proptest", "rusqlite", + "serde_json", "snafu", "stackable-odbc-core", "tracing", diff --git a/Cargo.toml b/Cargo.toml index c08bd9c..b1b0360 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,9 +29,9 @@ rusqlite = { version = "0.40", features = [ "column_metadata", "limits", ] } +serde_json = "1" snafu = "0.9" -# TODO: switch to a crates.io version dep once stackable-odbc-core is published. -stackable-odbc-core = { path = "../stackable-odbc-core" } +stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", branch = "scaffolding" } tracing = "0.1" [dev-dependencies] @@ -41,7 +41,7 @@ proptest = "1" # attach/detach helpers. Default-off there because it is test code that would # otherwise land in this driver's shipped binary; enabled only here, so # `cargo test` sees it and `cargo build` does not. -stackable-odbc-core = { path = "../stackable-odbc-core", features = ["test-support"] } +stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", branch = "scaffolding", features = ["test-support"] } [lints.clippy] unwrap_in_result = "deny" diff --git a/README.md b/README.md index cd253ae..8f1cac9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![Build and Test](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/build.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/build.yaml) [![Security Audit](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/security_audit.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-sqlite/actions/workflows/security_audit.yaml) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/stackabletech/stackable-odbc-sqlite/badge)](https://scorecard.dev/viewer/?uri=github.com/stackabletech/stackable-odbc-sqlite) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](https://docs.stackable.tech/home/stable/contributor/index.html) [![Apache License 2.0](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE) [![ODBC 3.80](https://img.shields.io/badge/ODBC-3.80-blue)](#what-it-deliberately-does-not-do) @@ -90,7 +91,9 @@ unixODBC; check it worked with `odbcinst -q -d`, which should list On Windows, unpack the `.zip` and run `install.bat` from an Administrator Command Prompt, then look for `stackable_odbc_sqlite` on the Drivers tab of -**ODBC Data Sources (64-bit)**. +**ODBC Data Sources (64-bit)**. From there, **Add…** opens the driver's own +dialog: name the data source, browse to a `.db` file, and press **Test +connection** to check it before saving. The full install, uninstall and DSN reference is in [`packaging/README.md`](packaging/README.md). @@ -147,11 +150,20 @@ isql -3 -k "Driver=$(pwd)/target/release/libstackable_odbc_sqlite.so;Database=$( reserved words is read out of the linked SQLite library at runtime instead of being copied from documentation that can drift. -- **Windows is a real target, not an afterthought.** It gets its own installer, - the DLL is cross-compiled and export-checked on every pull request, and the - test suite can be run through the Windows Driver Manager in a VM, which is far +- **Windows is a real target, not an afterthought.** It gets its own installer + and its own setup dialog, so the ODBC administrator's **Add…** button works + the way it does for a commercial driver. The DLL is cross-compiled, + export-checked and unit-tested on every pull request, and the integration + suite can be run through the Windows Driver Manager in a VM, which is far stricter than unixODBC and tends to fail silently rather than loudly. +- **Every release says what is inside it.** Both archives carry a CycloneDX + SBOM generated from the binary's own embedded dependency list rather than + from `Cargo.toml`, so it describes what was linked. That includes the + bundled SQLite and the Driver Manager the library loads, neither of which + cargo can see. The release page also carries SPDX, checksums and build + provenance attestations. + ## Connecting Connection strings are `Key=Value` pairs joined by `;`. Keys are @@ -175,7 +187,9 @@ Driver = stackable_odbc_sqlite Database = /path/to/your.db ``` -On Windows, see [`packaging/README.md`](packaging/README.md). +On Windows, the **Add…** button in the ODBC Data Source Administrator writes +one for you; see [`packaging/README.md`](packaging/README.md) for that and for +the scripted alternatives. ### Logging @@ -208,9 +222,9 @@ quietly faked, so a tool can react to it instead of trusting a wrong answer. - **One isolation level.** SQLite gives you serializable transactions, so that is the only level offered, and asking for a weaker one is refused up front rather than accepted and silently ignored. -- **No setup dialog.** The driver has no GUI, so the **Add** button in Windows' - ODBC administrator stores whatever it was handed without prompting you for a - database path. Create DSNs with `odbcconf` or by editing `odbc.ini` instead. +- **No setup dialog on Linux.** Windows gets one, from the **Add** button in + the ODBC administrator. unixODBC has no equivalent convention for a driver to + put a window on the screen, so on Linux a DSN is a section in `odbc.ini`. ## Testing @@ -237,12 +251,19 @@ and [integration-tests/windows/WINDOWS.md](integration-tests/windows/WINDOWS.md) for how to provision one. For the architecture, the conventions and the full testing reference, see -[AGENTS.md](AGENTS.md). +[AGENTS.md](AGENTS.md). For building it, the `[patch]` that points core at a +sibling checkout, and what has to pass before a commit, see +[CONTRIBUTING.md](CONTRIBUTING.md). ## Releasing See [packaging/README.md](packaging/README.md) for building the release -archives, and `release.toml` for the `cargo-release` configuration. +archives and how the SBOM is produced, and `release.toml` for the +`cargo-release` configuration. + +## Security + +Please report vulnerabilities privately; see [SECURITY.md](SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..03667f7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities privately, not through a public issue. + +The preferred channel is GitHub's private vulnerability reporting: open the +**Security** tab of this repository and choose **Report a vulnerability**. This +reaches the maintainers directly and keeps the report confidential until a fix +is available. + +If you cannot use that channel, email `info@stackable.tech` with `SECURITY` in +the subject line. + +Please include the driver version, the platform and Driver Manager in use, and +the steps needed to reproduce the issue. + +Note that SQLite itself is compiled into the driver rather than loaded from the +system, so an advisory against SQLite applies to whichever version this driver +bundles, not to the one installed on the machine. The bundled version is +recorded in every release archive's SBOM. + +## What to Expect + +We aim to acknowledge a report within three working days and to give an initial +assessment within ten. We will keep you informed while a fix is prepared, and we +will credit you in the advisory unless you ask us not to. + +## Supported Versions + +Security fixes are made against the most recent release and the `main` branch. +While the driver is below 1.0, fixes are not backported to earlier releases: +upgrade to the current release to receive them. + +## Disclosure + +Fixed vulnerabilities are published as GitHub Security Advisories against this +repository, naming the affected versions and the release that carries the fix. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f5b3d1b --- /dev/null +++ b/build.rs @@ -0,0 +1,201 @@ +//! Embeds a Windows `VERSIONINFO` resource in the driver DLL. +//! +//! The ODBC Data Source Administrator reads its **Version** and **Company** +//! columns from the driver file's version resource, and prints `Not marked` +//! for a file that carries none. Every Rust `cdylib` carries none, since rustc +//! emits no such resource. Measured on Windows Server 2022: `sqlsrv32.dll` +//! lists as `10.00.20348.01` / `Microsoft Corporation` and carries exactly +//! those two strings. +//! +//! Nothing here is hand-maintained. Every string comes from `Cargo.toml` +//! through cargo's own environment, so the resource cannot disagree with the +//! package. The version follows `CARGO_PKG_VERSION`, and therefore whatever +//! `cargo-release` wrote into `Cargo.toml`, which is why `release.toml` needs +//! no rule for this file. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); + println!("cargo:rerun-if-env-changed=WINDRES"); + + // `CARGO_CFG_TARGET_OS`, never `cfg!(windows)`: a build script is compiled + // for and run on the *host*, so `cfg!(windows)` describes the machine doing + // the building. The release DLL is cross-compiled from Linux, where it is + // false, so it would skip the resource on precisely the build that ships. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + // Only the GNU toolchain is wired up. An MSVC target needs `rc.exe` from a + // Visual Studio installation, which this repo never builds with: + // `.github/workflows/release.yaml` installs `gcc-mingw-w64-x86-64` and + // builds `x86_64-pc-windows-gnu`. Warn rather than fail, so a local MSVC + // build still works. It produces a DLL the Administrator lists as + // `Not marked`. + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_env != "gnu" { + println!( + "cargo:warning=no version resource embedded: the {target_env} Windows toolchain \ + needs rc.exe, and only the gnu toolchain's windres is wired up in build.rs. \ + The ODBC Data Source Administrator will list this driver as \"Not marked\"." + ); + return; + } + + let out_dir = PathBuf::from( + std::env::var("OUT_DIR").expect("cargo always sets OUT_DIR for a build script"), + ); + let rc_path = out_dir.join("version.rc"); + let obj_path = out_dir.join("version.o"); + + if let Err(e) = std::fs::write(&rc_path, version_rc()) { + fail(&format!("could not write {}: {e}", rc_path.display())); + } + + let windres = windres_command(); + let status = Command::new(&windres) + .arg("--input") + .arg(&rc_path) + .arg("--output") + .arg(&obj_path) + // COFF, so the result is an object file the linker takes like any + // other. windres defaults to emitting an `.rc` back out. + .arg("--output-format=coff") + .status(); + + match status { + Ok(s) if s.success() => {} + Ok(s) => fail(&format!( + "`{windres}` failed with {s} on {}", + rc_path.display() + )), + Err(e) => fail(&format!( + "could not run `{windres}`: {e}\n\ + A Windows build needs windres to embed the driver's version resource. \ + On Debian and Ubuntu it is in binutils-mingw-w64-x86-64, which \ + gcc-mingw-w64-x86-64 already depends on. Set WINDRES to override the name." + )), + } + + // `-cdylib`, not the unsuffixed form: the resource belongs to the shipped + // DLL, and the unsuffixed flag would also be handed to the linker for every + // test and benchmark binary. + println!("cargo:rustc-link-arg-cdylib={}", obj_path.display()); +} + +/// Abort the build with a message. +/// +/// `exit` rather than `panic!`, which the crate's clippy configuration denies. +/// Cargo renders a build script's stderr and its exit status as a build error +/// either way, and this way adds no backtrace for nobody to read. +fn fail(message: &str) -> ! { + eprintln!("error: {message}"); + std::process::exit(1); +} + +/// The `windres` to invoke. +/// +/// The cross-prefixed name first, because that is what a Linux host has: the +/// bare `windres` there, if it exists at all, targets the host. `WINDRES` +/// overrides both, for a toolchain under a different prefix. +fn windres_command() -> String { + if let Ok(explicit) = std::env::var("WINDRES") { + return explicit; + } + let prefixed = "x86_64-w64-mingw32-windres"; + if Command::new(prefixed).arg("--version").output().is_ok() { + return prefixed.to_string(); + } + "windres".to_string() +} + +/// The resource script, built entirely from cargo's environment. +fn version_rc() -> String { + let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_default(); + let major = env_num("CARGO_PKG_VERSION_MAJOR"); + let minor = env_num("CARGO_PKG_VERSION_MINOR"); + let patch = env_num("CARGO_PKG_VERSION_PATCH"); + let description = std::env::var("CARGO_PKG_DESCRIPTION").unwrap_or_default(); + let license = std::env::var("CARGO_PKG_LICENSE").unwrap_or_default(); + let company = company_name(); + + // The lib name is the package name with hyphens replaced, which is what + // both the `.so` and the `.dll` are named after. + let file_name = format!( + "{}.dll", + std::env::var("CARGO_PKG_NAME") + .unwrap_or_default() + .replace('-', "_") + ); + + // Literal numeric constants rather than `#include <windows.h>`, so the + // script does not depend on the mingw headers being on windres's include + // path: VOS_NT_WINDOWS32 (0x40004) and VFT_DLL (0x2). + // + // The 040904b0 block is US English, Unicode, and the VarFileInfo + // translation below must name the same pair or the strings are ignored. + format!( + r#"1 VERSIONINFO +FILEVERSION {major},{minor},{patch},0 +PRODUCTVERSION {major},{minor},{patch},0 +FILEOS 0x40004L +FILETYPE 0x2L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "{company}" + VALUE "FileDescription", "{description}" + VALUE "FileVersion", "{version}" + VALUE "InternalName", "{file_name}" + VALUE "LegalCopyright", "Copyright the {company} authors. Licensed under {license}." + VALUE "OriginalFilename", "{file_name}" + VALUE "ProductName", "{description}" + VALUE "ProductVersion", "{version}" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END +"#, + company = rc_escape(&company), + description = rc_escape(&description), + license = rc_escape(&license), + version = rc_escape(&version), + file_name = rc_escape(&file_name), + ) +} + +/// `CARGO_PKG_AUTHORS` without the address, so `Cargo.toml`'s +/// `Stackable GmbH <info@stackable.tech>` becomes the company the ODBC +/// Administrator shows. Only the first author: the column holds one name. +fn company_name() -> String { + let authors = std::env::var("CARGO_PKG_AUTHORS").unwrap_or_default(); + let first = authors.split(':').next().unwrap_or_default(); + first + .split('<') + .next() + .unwrap_or_default() + .trim() + .to_string() +} + +fn env_num(key: &str) -> u16 { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + +/// Quote and backslash are the two characters an `.rc` string cannot carry +/// raw. None of the values used here contains either today; escaping them +/// anyway keeps a future `description` from producing an unparseable script. +fn rc_escape(value: &str) -> String { + value.replace('\\', r"\\").replace('"', r#"\""#) +} diff --git a/deny.toml b/deny.toml index a11bdd7..b67e18c 100644 --- a/deny.toml +++ b/deny.toml @@ -36,3 +36,6 @@ private = { ignore = true } [sources] unknown-registry = "deny" unknown-git = "deny" +allow-git = [ + "https://github.com/stackabletech/stackable-odbc-core.git", +] diff --git a/integration-tests/windows/windows_test.py b/integration-tests/windows/windows_test.py index c0c7c57..41def02 100644 --- a/integration-tests/windows/windows_test.py +++ b/integration-tests/windows/windows_test.py @@ -31,9 +31,12 @@ SUITES_DIR = TEST_DIR / "suites" OPENSSL_CNF = WINDOWS_DIR / "openssl_legacy.cnf" +DIALOG_SCRIPT = PROJECT_DIR / "packaging" / "windows" / "configure-dsn.ps1" + REMOTE_DIR = r"C:\odbc_test" REMOTE_DLL = rf"{REMOTE_DIR}\stackable_odbc_sqlite.dll" REMOTE_TEST = rf"{REMOTE_DIR}\test_integration.py" +REMOTE_DIALOG = rf"{REMOTE_DIR}\configure-dsn.ps1" REMOTE_DB = rf"{REMOTE_DIR}\test.db" DRIVER_NAME = "stackable_odbc_sqlite" @@ -94,6 +97,11 @@ def main(): files_to_serve = { dll_path.name: dll_path, "test_integration.py": test_path, + # The setup dialog, which the driver's ConfigDSN looks for *beside its + # own DLL* and fails without. A DLL deployed here without it would + # answer the ODBC Administrator's Add... button with an error, so the + # two travel together the same way install.bat ships them together. + "configure-dsn.ps1": DIALOG_SCRIPT, } with http_file_server(files_to_serve) as port: base_url = f"http://{args.gateway}:{port}" @@ -102,7 +110,9 @@ def main(): f'Invoke-WebRequest -Uri "{base_url}/{dll_path.name}" ' f'-OutFile "{REMOTE_DLL}"; ' f'Invoke-WebRequest -Uri "{base_url}/test_integration.py" ' - f'-OutFile "{REMOTE_TEST}"' + f'-OutFile "{REMOTE_TEST}"; ' + f'Invoke-WebRequest -Uri "{base_url}/configure-dsn.ps1" ' + f'-OutFile "{REMOTE_DIALOG}"' ) r = session.run_ps(download_ps) if r.status_code != 0: @@ -111,6 +121,7 @@ def main(): sys.exit(1) print(f" DLL: {dll_path.stat().st_size / 1024:.0f} KB") print(f" test_integration.py: {test_path.stat().st_size / 1024:.0f} KB") + print(f" configure-dsn.ps1: {DIALOG_SCRIPT.stat().st_size / 1024:.0f} KB") print("=== Registering ODBC driver ===") register_driver(session) diff --git a/packaging/README.md b/packaging/README.md index 0686362..5276332 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,55 +1,47 @@ # Stackable SQLite ODBC Driver -ODBC 3.x driver for SQLite, primarily intended for testing and for -exercising the Stackable ODBC framework on a lightweight backend. +ODBC 3.x driver for [SQLite](https://sqlite.org), for Linux and Windows. +SQLite is compiled into the driver, so there is no separate SQLite to install. -## Building from source +This file ships inside both release archives. If you have just extracted one, +start at [Installation](#installation). -To produce the release archives yourself, run the following from the -**repository root**: +## What is in the archive -```bash -# One-time: add the Windows cross-compilation target -rustup target add x86_64-pc-windows-gnu - -# Build the Linux and Windows binaries -cargo build --release -cargo build --release --target x86_64-pc-windows-gnu - -# Package into release archives (replace the version as appropriate) -VERSION=0.0.1 ./packaging/build-archives.sh -``` +`stackable-odbc-sqlite-<version>-linux-x64.tar.gz`: -This produces two files in `packaging/dist/`: +| File | Purpose | +|------|---------| +| `libstackable_odbc_sqlite.so` | The driver | +| `install.sh`, `uninstall.sh` | Registration with unixODBC | +| `libstackable_odbc_sqlite.so.cdx.json` | CycloneDX SBOM for the driver | +| `README.md`, `LICENSE` | This file, and Apache-2.0 | -- `stackable-odbc-sqlite-<version>-linux-x64.tar.gz` -- `stackable-odbc-sqlite-<version>-windows-x64.zip` +`stackable-odbc-sqlite-<version>-windows-x64.zip`: -To install on Linux, extract and run the install script: - -```bash -mkdir /tmp/sqlite-odbc -tar xzf stackable-odbc-sqlite-0.0.1-linux-x64.tar.gz -C /tmp/sqlite-odbc -cd /tmp/sqlite-odbc -sudo ./install.sh -``` +| File | Purpose | +|------|---------| +| `stackable_odbc_sqlite.dll` | The driver | +| `install.bat`, `uninstall.bat` | Registration with the Windows Driver Manager | +| `configure-dsn.ps1` | The data source dialog | +| `stackable_odbc_sqlite.dll.cdx.json` | CycloneDX SBOM for the driver | +| `README.md`, `LICENSE` | This file, and Apache-2.0 | -On Windows, extract the `.zip` and run `install.bat` from an Administrator -Command Prompt. See the installation instructions below for details. +The release page carries `sha256sums.txt` over every published file. Verify a +download with `sha256sum -c sha256sums.txt`, run from the directory you +downloaded into. ## Installation -> **Note:** These instructions assume you are working from an extracted -> release archive, where the driver binary sits alongside the install -> scripts. If you are working from a source checkout, build the archives -> first (see above). - ### Linux (x86_64) -Requires `unixODBC` (`unixodbc` package) and root privileges for +Requires `unixODBC` (the `unixodbc` package) and root privileges for `odbcinst` registration. ```bash +mkdir /tmp/sqlite-odbc +tar xzf stackable-odbc-sqlite-<version>-linux-x64.tar.gz -C /tmp/sqlite-odbc +cd /tmp/sqlite-odbc sudo ./install.sh ``` @@ -63,19 +55,26 @@ sudo ./uninstall.sh ``` If you created any DSNs, also remove them from `/etc/odbc.ini` (or -`~/.odbc.ini`). +`~/.odbc.ini`). Your database files are untouched either way: a data source +only points at one. ### Windows (x86_64) -Open an **Administrator** Command Prompt (`cmd.exe`), then: +Extract the `.zip`, open an **Administrator** Command Prompt (`cmd.exe`) in the +extracted folder, then: ```cmd install.bat ``` +`install.bat` installs `configure-dsn.ps1` next to the DLL and refuses to run +without it, because that script is the dialog the ODBC Administrator's +**Add…** button displays. + Verify with the ODBC Data Source Administrator (`%SystemRoot%\System32\odbcad32.exe`); the Drivers tab should list -`stackable_odbc_sqlite`. +`stackable_odbc_sqlite`, with a version and `Stackable GmbH` rather than +`Not marked`. To uninstall: @@ -83,56 +82,129 @@ To uninstall: uninstall.bat ``` -If you created any DSNs, also remove them via the registry: +## Creating a data source + +A data source (DSN) stores the connection settings under a name, so an +application can ask for `sales_db` instead of a full connection string. It is +optional: the DSN-less connection strings below work without one. + +### Windows: the dialog + +The ODBC Data Source Administrator's **Add…** button, and **Configure…** on an +existing data source, both display this driver's dialog. It asks for the data +source name and the database file, and its **Test connection** button opens the +file and reports the SQLite version and the number of tables it found before +anything is written. + +The same dialog runs on its own, without going through the Administrator: ```cmd -reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\YourDsnName" /f -reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "YourDsnName" /f +powershell -ExecutionPolicy Bypass -File "%ProgramFiles%\Stackable\ODBC\configure-dsn.ps1" ``` -## Create a DSN (optional) +```cmd +rem Edit an existing data source +powershell -ExecutionPolicy Bypass -File "...\configure-dsn.ps1" -Dsn sales_db +``` -A DSN stores connection parameters so that users don't need the full -connection string each time. This step is optional, since DSN-less connection -strings (shown below) work without it. +### Windows: without a dialog -On Windows (`cmd.exe`): +For a scripted install, the same script writes a data source with no GUI: ```cmd -odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=SQLite Test|Database=C:\data\test.db|"} +powershell -ExecutionPolicy Bypass -File "...\configure-dsn.ps1" ^ + -NoGui -Set @{ DSN='sales_db'; Database='C:\data\sales.db' } +``` + +Or through `odbcconf` directly: + +```cmd +odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=sales_db|Database=C:\data\sales.db|"} ``` > **PowerShell users:** `odbcconf.exe` commands with `{...}` use `cmd.exe` > syntax. In PowerShell, wrap the argument in single quotes: > `odbcconf.exe /A '{CONFIGDSN ...}'`. -The DSN will appear under the **User DSN** tab in ODBC Data Source -Administrator. +A **System** data source (visible to every user, stored in `HKLM`) needs an +elevated session. The dialog disables the System option when it does not have +one, and `-NoGui -System` fails rather than writing a User data source +silently. -> **Note:** the driver registers itself as its own `Setup` library and -> implements `ConfigDSNW`, but headlessly: it never displays a dialog. The -> **Add** button therefore does not fail, it silently writes a data source -> from whatever attributes the Driver Manager passed it, which will not -> include `Database`. Create DSNs with `odbcconf` or the registry so that -> every key is set. +### Linux -On Linux, add a section to `/etc/odbc.ini` (or `~/.odbc.ini` for a -per-user DSN): +Add a section to `/etc/odbc.ini` (or `~/.odbc.ini` for a per-user DSN): ```ini -[SQLite Test] +[sales_db] Driver = stackable_odbc_sqlite -Database = /path/to/your.db +Database = /path/to/sales.db ``` ## Connection string +There is exactly one key. Keys are case-insensitive. + +| Key | Required | Meaning | +|-----|----------|---------| +| `Database` | Yes | Path to the SQLite file, or `:memory:` for a throwaway in-memory database | + DSN-less: +```text +Driver=stackable_odbc_sqlite;Database=/path/to/sales.db ``` -Driver=stackable_odbc_sqlite;Database=/path/to/your.db + +A file that does not exist yet is created on first connect, because that is +what SQLite does. A typo in the path therefore connects successfully and finds +an empty database rather than failing, which is why the dialog's **Test +connection** reports the table count. + +## Building the archives from source + +From the **repository root**: + +```bash +# One-time: the Windows cross-compilation target and the two packaging tools +rustup target add x86_64-pc-windows-gnu +cargo install cargo-auditable +# syft: https://github.com/anchore/syft + +# Build both binaries. `cargo auditable`, not plain `cargo`: it embeds the +# dependency list that the SBOM is generated from, and sbom.sh refuses an +# artifact without it. +cargo auditable build --locked --release +cargo auditable build --locked --release --target x86_64-pc-windows-gnu + +VERSION=0.0.1 ./packaging/build-archives.sh ``` +That writes both archives, four SBOMs and `sha256sums.txt` to +`packaging/dist/`. + +### The SBOM + +`packaging/sbom.sh` produces one CycloneDX and one SPDX document per artifact. +The component list comes from the `.dep-v0` section `cargo auditable` embeds, +so it describes what was **linked** rather than what `Cargo.toml` asked for: +dev-dependencies are excluded by construction, and a git dependency's purl +names the resolved commit rather than a branch that moves. + +Two kinds of component are invisible to cargo and are declared by hand in +`packaging/sbom-native.json`: + +- **SQLite itself.** cargo sees `libsqlite3-sys`, the Rust wrapper. The C + library compiled inside it is what an advisory against SQLite would name, and + it ships in both artifacts. `the_declared_sqlite_version_is_the_one_linked` + in `src/lib.rs` fails the build if the declared version drifts from what + `rusqlite::version()` reports. +- **What each artifact links at load time.** The `.so` links unixODBC; the + `.dll` imports only Windows' own libraries and carries the mingw runtime + statically. `./packaging/sbom.sh --check-native <artifact>` verifies both + claims against the real binary, and CI runs it on every pull request. + +`./packaging/test-sbom.sh` is the pipeline's own test suite. + ## Support <https://github.com/stackabletech/stackable-odbc-sqlite> diff --git a/packaging/build-archives.sh b/packaging/build-archives.sh index fc63dd8..56e3147 100755 --- a/packaging/build-archives.sh +++ b/packaging/build-archives.sh @@ -5,27 +5,35 @@ # - $VERSION environment variable set (e.g. "1.0.0-beta.1") # - target/release/libstackable_odbc_sqlite.so exists # - target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll exists +# - both built with `cargo auditable`, which embeds the .dep-v0 section the +# SBOM is generated from. sbom.sh refuses an artifact without it. +# - syft on PATH # -# Output (written to dist/): +# Output (written to packaging/dist/): # - stackable-odbc-sqlite-<version>-linux-x64.tar.gz # - stackable-odbc-sqlite-<version>-windows-x64.zip +# - a CycloneDX and an SPDX SBOM per artifact, four files +# - sha256sums.txt over everything above +# +# Each archive also carries the CycloneDX SBOM for what it contains, so an +# offline or air-gapped install has it without going back to the release page. set -euo pipefail : "${VERSION:?VERSION environment variable must be set}" -PACKAGING_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$PACKAGING_DIR/.." && pwd)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGING_DIR="$REPO_ROOT/packaging" DIST_DIR="$PACKAGING_DIR/dist" LINUX_SO="$REPO_ROOT/target/release/libstackable_odbc_sqlite.so" WINDOWS_DLL="$REPO_ROOT/target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll" LICENSE_FILE="$REPO_ROOT/LICENSE" if [ ! -f "$LINUX_SO" ]; then - echo "ERROR: $LINUX_SO not found. Run 'cargo build --release' first." >&2 + echo "ERROR: $LINUX_SO not found. Run 'cargo auditable build --release' first." >&2 exit 1 fi if [ ! -f "$WINDOWS_DLL" ]; then - echo "ERROR: $WINDOWS_DLL not found. Run 'cargo build --release --target x86_64-pc-windows-gnu' first." >&2 + echo "ERROR: $WINDOWS_DLL not found. Run 'cargo auditable build --release --target x86_64-pc-windows-gnu' first." >&2 exit 1 fi if [ ! -f "$LICENSE_FILE" ]; then @@ -35,6 +43,19 @@ fi mkdir -p "$DIST_DIR" +# --- SBOMs --- +# Generated first, because each archive carries the one describing its contents. +# sbom.sh writes <basename>.cdx.json and <basename>.spdx.json. +SBOM_DIR="$DIST_DIR/sbom" +rm -rf "$SBOM_DIR" +mkdir -p "$SBOM_DIR" + +"$PACKAGING_DIR/sbom.sh" "$LINUX_SO" "$SBOM_DIR" +"$PACKAGING_DIR/sbom.sh" "$WINDOWS_DLL" "$SBOM_DIR" + +LINUX_SBOM="$SBOM_DIR/$(basename "$LINUX_SO").cdx.json" +WINDOWS_SBOM="$SBOM_DIR/$(basename "$WINDOWS_DLL").cdx.json" + # --- Linux archive --- LINUX_STAGING="$DIST_DIR/staging-linux" rm -rf "$LINUX_STAGING" @@ -44,6 +65,7 @@ cp "$PACKAGING_DIR/linux/install.sh" "$LINUX_STAGING/" cp "$PACKAGING_DIR/linux/uninstall.sh" "$LINUX_STAGING/" cp "$PACKAGING_DIR/README.md" "$LINUX_STAGING/" cp "$LICENSE_FILE" "$LINUX_STAGING/" +cp "$LINUX_SBOM" "$LINUX_STAGING/" chmod +x "$LINUX_STAGING/install.sh" "$LINUX_STAGING/uninstall.sh" LINUX_ARCHIVE="stackable-odbc-sqlite-${VERSION}-linux-x64.tar.gz" @@ -51,19 +73,42 @@ tar -czf "$DIST_DIR/$LINUX_ARCHIVE" -C "$LINUX_STAGING" . rm -rf "$LINUX_STAGING" # --- Windows archive --- +# configure-dsn.ps1 is not an extra: install.bat refuses to register the driver +# without it, because it is the dialog the ODBC Administrator's "Add..." button +# displays. WINDOWS_STAGING="$DIST_DIR/staging-windows" rm -rf "$WINDOWS_STAGING" mkdir -p "$WINDOWS_STAGING" cp "$WINDOWS_DLL" "$WINDOWS_STAGING/" cp "$PACKAGING_DIR/windows/install.bat" "$WINDOWS_STAGING/" cp "$PACKAGING_DIR/windows/uninstall.bat" "$WINDOWS_STAGING/" +cp "$PACKAGING_DIR/windows/configure-dsn.ps1" "$WINDOWS_STAGING/" cp "$PACKAGING_DIR/README.md" "$WINDOWS_STAGING/" cp "$LICENSE_FILE" "$WINDOWS_STAGING/" +cp "$WINDOWS_SBOM" "$WINDOWS_STAGING/" WINDOWS_ARCHIVE="stackable-odbc-sqlite-${VERSION}-windows-x64.zip" (cd "$WINDOWS_STAGING" && zip -r "$DIST_DIR/$WINDOWS_ARCHIVE" .) rm -rf "$WINDOWS_STAGING" +# --- SBOMs as release assets --- +# Named with the version, so an asset downloaded on its own still says which +# release it describes. +for fmt in cdx spdx; do + cp "$SBOM_DIR/$(basename "$LINUX_SO").$fmt.json" \ + "$DIST_DIR/stackable-odbc-sqlite-${VERSION}-linux-x64.$fmt.json" + cp "$SBOM_DIR/$(basename "$WINDOWS_DLL").$fmt.json" \ + "$DIST_DIR/stackable-odbc-sqlite-${VERSION}-windows-x64.$fmt.json" +done +rm -rf "$SBOM_DIR" + +# --- Checksums --- +# Over every published file, generated last so it covers the SBOMs too. Paths +# are relative, so `sha256sum -c sha256sums.txt` works from the download +# directory. +(cd "$DIST_DIR" && sha256sum ./*.tar.gz ./*.zip ./*.json > sha256sums.txt) + echo "Built:" echo " $DIST_DIR/$LINUX_ARCHIVE" echo " $DIST_DIR/$WINDOWS_ARCHIVE" +echo " $DIST_DIR/sha256sums.txt ($(wc -l < "$DIST_DIR/sha256sums.txt") entries)" diff --git a/packaging/sbom-native.json b/packaging/sbom-native.json new file mode 100644 index 0000000..bb64139 --- /dev/null +++ b/packaging/sbom-native.json @@ -0,0 +1,93 @@ +{ + "_comment": "Components the toolchain contributes, which cargo cannot see. `common` is merged into every artifact; `linux` and `windows` only into theirs. The version recorded for a dynamically linked component is the one built against on the build host; the version actually loaded is whatever the user's machine provides. A statically linked one is redistributed inside the artifact, so its version is exact. Verified against readelf -d by `sbom.sh --check-native`.", + "_common_comment": "SQLite is compiled from the amalgamation that libsqlite3-sys vendors, under its `bundled` feature, so it ships inside both artifacts and is not loaded from the machine. cargo sees the *wrapper* crate, libsqlite3-sys, and nothing about the C library inside it, which is the component an advisory against SQLite would name. The version must match the SQLITE_VERSION in libsqlite3-sys's sqlite3.h; `the_declared_sqlite_version_is_the_one_linked` in src/lib.rs fails the build if it drifts.", + "common": [ + { + "type": "library", + "name": "sqlite", + "version": "3.53.2", + "purl": "pkg:generic/sqlite@3.53.2", + "description": "SQLite, compiled into the driver from the amalgamation vendored by libsqlite3-sys. The database engine itself, rather than the Rust binding to it.", + "licenses": [ + { + "license": { + "id": "blessing" + } + } + ], + "properties": [ + { + "name": "stackable:link-kind", + "value": "static" + } + ] + } + ], + "linux": [ + { + "type": "library", + "name": "unixodbc", + "version": "2.3.12", + "purl": "pkg:generic/unixodbc@2.3.12", + "description": "unixODBC Driver Manager. libodbcinst.so.2 is linked at load time for driver and DSN registry access.", + "licenses": [ + { + "license": { + "id": "LGPL-2.1-or-later" + } + } + ], + "properties": [ + { + "name": "stackable:link-kind", + "value": "dynamic" + }, + { + "name": "stackable:soname", + "value": "libodbcinst.so.2" + } + ] + } + ], + "_windows_comment": "The DLL imports only Windows' own libraries, odbccp32.dll included, and those are the platform rather than dependencies, so none is listed here for the same reason libc is not listed for Linux. What is listed is the toolchain runtime, which is linked statically and therefore redistributed inside the artifact: the DLL imports no libgcc_s_seh-1.dll, libwinpthread-1.dll or libstdc++-6.dll, while carrying mingw_*, __gcc_register_frame, _Unwind_* and pthread_* internally.", + "windows": [ + { + "type": "library", + "name": "mingw-w64-runtime", + "version": "11.0.1", + "purl": "pkg:generic/mingw-w64-runtime@11.0.1", + "description": "mingw-w64 C runtime and winpthreads, statically linked into the DLL by the x86_64-pc-windows-gnu target.", + "licenses": [ + { + "license": { + "name": "Permissive mix: BSD-2-Clause-NetBSD, BSD-3-Clause, ISC, Cygwin and David-Gay. No single SPDX identifier covers it; see the mingw-w64 COPYING." + } + } + ], + "properties": [ + { + "name": "stackable:link-kind", + "value": "static" + } + ] + }, + { + "type": "library", + "name": "libgcc", + "version": "13.2.0", + "purl": "pkg:generic/libgcc@13.2.0", + "description": "GCC low-level runtime and unwinder, statically linked into the DLL by the x86_64-pc-windows-gnu target.", + "licenses": [ + { + "expression": "GPL-3.0-or-later WITH GCC-exception-3.1" + } + ], + "properties": [ + { + "name": "stackable:link-kind", + "value": "static" + } + ] + } + ] +} diff --git a/packaging/sbom.sh b/packaging/sbom.sh new file mode 100755 index 0000000..fe60a56 --- /dev/null +++ b/packaging/sbom.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# Generate a CycloneDX and an SPDX SBOM for one release artifact. +# +# Usage: +# sbom.sh <artifact> <outdir> write <outdir>/<basename>.{cdx,spdx}.json +# sbom.sh --check-native <artifact> +# verify sbom-native.json against what the +# artifact actually links +# +# The artifact must be built with `cargo auditable`, which embeds a .dep-v0 +# section holding the crates that were linked in. Syft reads that section, so the +# component list describes what shipped rather than what Cargo.toml asked for, +# and dev-dependencies are excluded by construction. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Overridable so the tests can feed a drifted fragment on purpose. +SBOM_NATIVE="${SBOM_NATIVE:-$REPO_ROOT/packaging/sbom-native.json}" + +usage() { + cat >&2 <<'EOF' +usage: sbom.sh <artifact> <outdir> + write <outdir>/<basename>.cdx.json and .spdx.json + + sbom.sh --check-native <artifact> + verify sbom-native.json against what the artifact actually links +EOF + exit 2 +} + +# Libraries supplied by the toolchain and libc are the platform, not components, +# so they are excluded the same way the Windows branch excludes the operating +# system's own DLLs. Everything else the ELF object needs at load time must be +# declared in the fragment. +IGNORED_SONAMES='^(libc\.so\.|libm\.so\.|libpthread\.so\.|libdl\.so\.|librt\.so\.|libgcc_s\.so\.|ld-linux)' + +# The Windows artifact declares no load-time component at all, because it +# imports only the operating system's libraries. What must hold instead is that +# the toolchain runtime stays *statically* linked: the release archive ships no +# runtime DLL, so an artifact importing one would fail to load on a machine +# without mingw installed. +FORBIDDEN_WINDOWS_IMPORTS='^(libgcc_s_seh-1|libgcc_s_dw2-1|libwinpthread-1|libstdc\+\+-6)\.dll$' + +check_native_elf() { + local artifact="$1" needed declared + needed="$(readelf -d "$artifact" \ + | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p' \ + | grep -Ev "$IGNORED_SONAMES" \ + | sort)" + # Only entries carrying a soname take part: a statically linked component, + # SQLite included, has none by construction and would otherwise read as drift. + declared="$(jq -r '(.common + .linux)[].properties[]? | select(.name == "stackable:soname") | .value' \ + "$SBOM_NATIVE" | sort)" + + if [ "$needed" = "$declared" ]; then + echo "PASS: sbom-native.json matches the artifact's DT_NEEDED set" + return 0 + fi + + echo "FAIL: sbom-native.json has drifted from $artifact" >&2 + echo " linked but undeclared:" >&2 + comm -23 <(echo "$needed") <(echo "$declared") | sed 's/^/ /' >&2 + echo " declared but not linked:" >&2 + comm -13 <(echo "$needed") <(echo "$declared") | sed 's/^/ /' >&2 + return 1 +} + +check_native_pe() { + local artifact="$1" dynamic + dynamic="$(objdump -p "$artifact" \ + | sed -n 's/^\tDLL Name: //p' \ + | tr '[:upper:]' '[:lower:]' \ + | sort -u \ + | grep -E "$FORBIDDEN_WINDOWS_IMPORTS" || true)" + + if [ -z "$dynamic" ]; then + echo "PASS: the toolchain runtime is statically linked into the artifact" + return 0 + fi + + echo "FAIL: $artifact imports the toolchain runtime dynamically" >&2 + echo "$dynamic" | sed 's/^/ /' >&2 + echo " The release archive ships no runtime DLL, so this artifact would fail" >&2 + echo " to load on a machine without mingw installed. Either restore static" >&2 + echo " linking or ship the runtime and declare it in sbom-native.json." >&2 + return 1 +} + +if [ "${1:-}" = "--check-native" ]; then + [ "$#" -eq 2 ] || usage + [ -f "$2" ] || { echo "ERROR: artifact not found: $2" >&2; exit 1; } + case "$2" in + *.so) check_native_elf "$2" ;; + *.dll) check_native_pe "$2" ;; + *) echo "ERROR: cannot check native links of $2" >&2; exit 1 ;; + esac + exit $? +fi + +[ "$#" -eq 2 ] || usage + +ARTIFACT="$1" +OUTDIR="$2" + +[ -f "$ARTIFACT" ] || { echo "ERROR: artifact not found: $ARTIFACT" >&2; exit 1; } +mkdir -p "$OUTDIR" + +BASENAME="$(basename "$ARTIFACT")" +OUT="$OUTDIR/$BASENAME.cdx.json" +OUT_SPDX="$OUTDIR/$BASENAME.spdx.json" + +# An artifact built with plain `cargo build` carries no .dep-v0 section, and +# syft then reports a handful of components rather than the whole graph. That +# failure is silent and the result looks like a valid SBOM, so refuse it here +# rather than shipping a document that understates what is in the binary. +case "$ARTIFACT" in + *.so) FOUND="$(readelf -S -W "$ARTIFACT" 2>/dev/null | grep -c '\.dep-v0' || true)" ;; + *.dll) FOUND="$(objdump -h "$ARTIFACT" 2>/dev/null | grep -c '\.dep-v0' || true)" ;; + *) echo "ERROR: cannot build an SBOM for $ARTIFACT" >&2; exit 1 ;; +esac +if [ "${FOUND:-0}" -eq 0 ]; then + echo "ERROR: $ARTIFACT carries no .dep-v0 section." >&2 + echo " It was built with plain cargo, so the dependency graph is not in it" >&2 + echo " and the SBOM would list only a few components. Rebuild with:" >&2 + case "$ARTIFACT" in + *.dll) echo " cargo auditable build --release --target x86_64-pc-windows-gnu" >&2 ;; + *) echo " cargo auditable build --release" >&2 ;; + esac + exit 1 +fi + +RAW="$OUTDIR/.$BASENAME.raw.json" +LOOKUP="$OUTDIR/.$BASENAME.lookup.json" +ENRICHED="$OUTDIR/.$BASENAME.enriched.json" +AUGMENTED="$OUTDIR/.$BASENAME.augmented.json" + +# --- extract --------------------------------------------------------------- +syft "$ARTIFACT" -o cyclonedx-json="$RAW" --quiet + +# --- enrich ---------------------------------------------------------------- +# cargo-auditable embeds only name, version and source kind, so syft's output +# carries no licenses, and a git or path dependency is indistinguishable from a +# crates.io package. A scanner resolving pkg:cargo/stackable-odbc-core@0.0.1 +# would reach a crates.io package that does not exist yet. +# +# Everything below keys off cargo metadata's source *kind*, never off a crate +# name, so a dependency moving between path, git and crates.io needs no change +# here. +cargo metadata --locked --format-version 1 --manifest-path "$REPO_ROOT/Cargo.toml" \ + | jq '[ .packages[] + | { key: "\(.name)@\(.version)", + value: { + license: .license, + kind: (if .source == null then "path" + elif (.source | startswith("git+")) then "git" + else "registry" end), + vcs: (if ((.source // "") | startswith("git+")) + then "git+" + (.source | sub("^git\\+"; "") | sub("[?#].*$"; "")) + + "@" + (.source | capture("#(?<rev>[0-9a-f]+)$").rev) + else null end) + } } ] | from_entries' > "$LOOKUP" + +# The rev comes from the resolved source in Cargo.lock, not from the branch or +# tag name, so the purl names an immutable commit. +jq --slurpfile lut "$LOOKUP" ' + ($lut[0]) as $L + | .components |= map( + . as $c + | ($L["\($c.name)@\($c.version)"]) as $m + | if $m == null then . else + .licenses = ( + if $m.license == null then [] + elif ($m.license | test(" OR | AND |/")) + then [ { expression: $m.license } ] + else [ { license: { id: $m.license } } ] end) + | .purl = ( + if $m.kind == "git" then "\(.purl)?vcs_url=\($m.vcs)" + elif $m.kind == "path" then "pkg:generic/\(.name)@\(.version)" + else .purl end) + | .properties = ( + (.properties // [] | map(select(.name | startswith("syft:cpe23") | not))) + + (if $m.kind == "path" + then [ { name: "stackable:cargo-source", value: "path" } ] + else [] end)) + end)' "$RAW" > "$ENRICHED" + +# --- augment --------------------------------------------------------------- +# Components the toolchain contributes are invisible to cargo. `common` holds +# the ones both artifacts carry -- SQLite itself, compiled in from the +# amalgamation -- and the platform key holds the rest: the ELF object links +# unixODBC at load time, while the Windows DLL imports only the operating +# system's own libraries and instead carries the mingw runtime statically. +case "$BASENAME" in + *.so) NATIVE_KEY="linux" ;; + *.dll) NATIVE_KEY="windows" ;; +esac + +jq --slurpfile native "$SBOM_NATIVE" \ + --arg key "$NATIVE_KEY" \ + '.components += (($native[0].common // []) + ($native[0][$key] // []))' \ + "$ENRICHED" > "$AUGMENTED" + +# --- finalize -------------------------------------------------------------- +# Syft reports the scanned artifact as an ordinary component: type "file" named +# by its absolute path on the build host, and for the PE artifact a second +# type "application" entry as well. Both are the *subject* of this document +# rather than dependencies, so they move to metadata.component, and the build +# path stops travelling with the release. +# +# They are selected by having no purl rather than by type, because the types +# differ between the two artifact formats. Every real component has one: the +# cargo crates from the enrich stage, the native ones from the fragment. +ARTIFACT_SHA="$(sha256sum "$ARTIFACT" | cut -d' ' -f1)" +RUSTC_VERSION="$(rustc --version)" + +jq --arg name "$BASENAME" \ + --arg sha "$ARTIFACT_SHA" \ + --arg rustc "$RUSTC_VERSION" \ + ' + .components |= map(select((.purl // "") != "")) + | .metadata.component = { + type: "library", + name: $name, + hashes: [ { alg: "SHA-256", content: $sha } ] + } + | .metadata.properties = ((.metadata.properties // []) + [ + { name: "stackable:rustc-version", value: $rustc } + ])' "$AUGMENTED" > "$OUT" + +# --- convert --------------------------------------------------------------- +# SPDX is converted from the finished CycloneDX rather than generated afresh, so +# the enrichment and the native fragment reach both formats from one +# implementation and cannot drift apart. Some procurement processes ask for SPDX +# by name; CycloneDX is what ships inside the archive. +syft convert "$OUT" -o spdx-json="$OUT_SPDX" --quiet + +rm -f "$RAW" "$LOOKUP" "$ENRICHED" "$AUGMENTED" + +echo "Wrote $OUT" +echo "Wrote $OUT_SPDX" diff --git a/packaging/test-sbom.sh b/packaging/test-sbom.sh new file mode 100755 index 0000000..fc10aaf --- /dev/null +++ b/packaging/test-sbom.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Assertions for packaging/sbom.sh, run against the real release artifacts. +# +# Needs syft and cargo-auditable. Builds the .so if absent; the Windows checks +# are skipped unless the DLL has been cross-compiled too. +# Run from anywhere: ./packaging/test-sbom.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SO="$REPO_ROOT/target/release/libstackable_odbc_sqlite.so" +DLL="$REPO_ROOT/target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +FAILURES=0 +check() { + local label="$1" actual="$2" expected="$3" + if [ "$actual" = "$expected" ]; then + echo "PASS $label" + else + echo "FAIL $label: expected '$expected', got '$actual'" + FAILURES=$((FAILURES + 1)) + fi +} + +if [ ! -f "$SO" ]; then + echo "Building the release artifact with cargo auditable..." + (cd "$REPO_ROOT" && cargo auditable build --locked --release) +fi + +"$REPO_ROOT/packaging/sbom.sh" "$SO" "$WORK" +SBOM="$WORK/libstackable_odbc_sqlite.so.cdx.json" +SPDX="$WORK/libstackable_odbc_sqlite.so.spdx.json" + +check "SBOM file is written" "$([ -f "$SBOM" ] && echo yes || echo no)" "yes" +check "SPDX file is written" "$([ -f "$SPDX" ] && echo yes || echo no)" "yes" + +# The whole point of reading .dep-v0 rather than Cargo.toml is that the list is +# the linked graph, so it has to be a graph rather than a handful of entries. +# A bound rather than an exact count: an exact one turns every dependency bump +# into a failing test that says nothing about the change. +check "the component list is the whole graph" \ + "$(jq '.components | length >= 30' "$SBOM")" "true" + +check "every component is licensed" \ + "$(jq '[.components[] | select((.licenses // []) | length == 0)] | length' "$SBOM")" "0" + +check "syft cpe23 noise is stripped" \ + "$(jq '[.components[].properties[]? | select(.name | startswith("syft:cpe23"))] | length' "$SBOM")" "0" + +check "dev-dependencies are absent" \ + "$(jq '[.components[] | select(.name | test("^(criterion|proptest)$"))] | length' "$SBOM")" "0" + +check "the artifact is the SBOM subject" \ + "$(jq -r '.metadata.component.name' "$SBOM")" "libstackable_odbc_sqlite.so" + +check "the subject carries a sha256" \ + "$(jq -r '.metadata.component.hashes[]? | select(.alg == "SHA-256") | .content' "$SBOM" | tr -d '\n' | wc -c)" "64" + +check "no absolute build path leaks" \ + "$(jq -r '[.. | strings | select(startswith("/home/") or startswith("/build/"))] | length' "$SBOM")" "0" + +check "the rust toolchain is recorded" \ + "$(jq '[.metadata.properties[]? | select(.name == "stackable:rustc-version")] | length' "$SBOM")" "1" + +# Core is pinned by branch, so its commit moves with every core change. Assert +# the shape rather than the value: a resolved 40-character commit, never the +# branch name, or the purl would name whatever that branch points at today. +check "core's purl names an immutable commit" \ + "$(jq -r '[.components[] | select(.name == "stackable-odbc-core") + | select(.purl | test("\\?vcs_url=git\\+https://github\\.com/stackabletech/stackable-odbc-core\\.git@[0-9a-f]{40}$"))] | length' "$SBOM")" "1" + +# One: stackable-odbc-sqlite, the root package, which is path-local permanently. +# The gate is not "zero path components": it is that the only path-sourced +# component is the root package, which is what catches a developer's local +# `[patch]` override shipping in a release artifact. +check "the only path-sourced component is the root package" \ + "$(jq -r '[.components[] + | select(.properties[]? | select(.name == "stackable:cargo-source" and .value == "path")) + | .name] | join(",")' "$SBOM")" \ + "stackable-odbc-sqlite" + +# --- the bundled SQLite ---------------------------------------------------- +# The reason this repo has a `common` fragment at all. cargo sees the wrapper +# crate, libsqlite3-sys; the C library compiled inside it is what an advisory +# against SQLite names, and only sbom-native.json carries it. +# That the version it carries is the version actually linked is asserted in +# Rust instead, by `the_declared_sqlite_version_is_the_one_linked` in +# src/lib.rs: it compares the fragment against `rusqlite::version()`, which is +# the linked library answering for itself rather than a file path guessed at. +check "SQLite itself is a component, not just its wrapper" \ + "$(jq '[.components[] | select(.name == "sqlite")] | length' "$SBOM")" "1" + +check "the native Linux component is merged in" \ + "$(jq '[.components[] | select(.name == "unixodbc")] | length' "$SBOM")" "1" + +check "the native component keeps its soname" \ + "$(jq -r '.components[] | select(.name == "unixodbc") + | .properties[] | select(.name == "stackable:soname") | .value' "$SBOM")" \ + "libodbcinst.so.2" + +check "the Windows runtime is not merged into a Linux SBOM" \ + "$(jq '[.components[] | select(.name == "libgcc" or .name == "mingw-w64-runtime")] | length' "$SBOM")" "0" + +# --- SPDX ------------------------------------------------------------------ +# SPDX is converted from the enriched CycloneDX rather than generated afresh, so +# the enrichment reaches both formats from one implementation. These assert the +# conversion carries it across. + +check "SPDX carries the enriched licenses" \ + "$(jq '[.packages[] | select(.externalRefs[]? | .referenceType == "purl") + | select((.licenseDeclared // "NOASSERTION") == "NOASSERTION")] | length' "$SPDX")" "0" + +check "SPDX carries the native components" \ + "$(jq '[.packages[] | select(.name == "unixodbc" or .name == "sqlite")] | length' "$SPDX")" "2" + +check "SPDX leaks no build path" \ + "$(jq '[.. | strings | select(startswith("/home/") or startswith("/build/"))] | length' "$SPDX")" "0" + +# --- --check-native -------------------------------------------------------- + +check "--check-native passes on the current fragment" \ + "$("$REPO_ROOT/packaging/sbom.sh" --check-native "$SO" >/dev/null 2>&1 && echo ok || echo failed)" "ok" + +# Drift must be detected, not tolerated. Feed it a fragment with the entry +# removed and require a non-zero exit. +jq 'del(.linux[0])' "$REPO_ROOT/packaging/sbom-native.json" > "$WORK/drifted.json" +check "--check-native detects a missing entry" \ + "$(SBOM_NATIVE="$WORK/drifted.json" "$REPO_ROOT/packaging/sbom.sh" --check-native "$SO" >/dev/null 2>&1 && echo ok || echo failed)" "failed" + +# The wrong soname must be caught too, which is the mistake of naming libodbc +# where the artifact links libodbcinst. +jq '.linux[0].properties |= map(if .name == "stackable:soname" then .value = "libodbc.so.2" else . end)' \ + "$REPO_ROOT/packaging/sbom-native.json" > "$WORK/wrong-soname.json" +check "--check-native detects a wrong soname" \ + "$(SBOM_NATIVE="$WORK/wrong-soname.json" "$REPO_ROOT/packaging/sbom.sh" --check-native "$SO" >/dev/null 2>&1 && echo ok || echo failed)" "failed" + +# --- the Windows artifact -------------------------------------------------- +# Generated as well as checked, because the two artifact formats take different +# branches through augment and finalize. Syft emits a second self-entry of type +# "application" for the PE artifact, which the Linux run never exercises. +if [ -f "$DLL" ]; then + check "--check-native passes on the Windows DLL" \ + "$("$REPO_ROOT/packaging/sbom.sh" --check-native "$DLL" >/dev/null 2>&1 && echo ok || echo failed)" "ok" + + "$REPO_ROOT/packaging/sbom.sh" "$DLL" "$WORK" >/dev/null + WSBOM="$WORK/stackable_odbc_sqlite.dll.cdx.json" + + check "Windows: every component is licensed" \ + "$(jq '[.components[] | select((.licenses // []) | length == 0)] | length' "$WSBOM")" "0" + + check "Windows: no self-entry survives" \ + "$(jq '[.components[] | select((.purl // "") == "")] | length' "$WSBOM")" "0" + + check "Windows: the toolchain runtime is declared" \ + "$(jq -r '[.components[] | select(.name == "mingw-w64-runtime" or .name == "libgcc") | .name] | sort | join(",")' "$WSBOM")" \ + "libgcc,mingw-w64-runtime" + + check "Windows: SQLite is declared there too" \ + "$(jq '[.components[] | select(.name == "sqlite")] | length' "$WSBOM")" "1" + + check "Windows: unixODBC is not merged in" \ + "$(jq '[.components[] | select(.name == "unixodbc")] | length' "$WSBOM")" "0" + + check "Windows: no absolute build path leaks" \ + "$(jq '[.. | strings | select(startswith("/home/") or startswith("/build/"))] | length' "$WSBOM")" "0" + + check "Windows: the artifact is the SBOM subject" \ + "$(jq -r '.metadata.component.name' "$WSBOM")" "stackable_odbc_sqlite.dll" +else + echo "SKIP Windows checks: DLL not built (cargo auditable build --release --target x86_64-pc-windows-gnu)" +fi + +# An artifact built without cargo auditable must be refused, not silently turned +# into a near-empty SBOM. Strip the section to prove the guard fires. +cp "$SO" "$WORK/no-audit.so" +objcopy --remove-section=.dep-v0 "$WORK/no-audit.so" 2>/dev/null || true +check "an artifact without .dep-v0 is refused" \ + "$("$REPO_ROOT/packaging/sbom.sh" "$WORK/no-audit.so" "$WORK/refused" >/dev/null 2>&1 && echo ok || echo refused)" "refused" + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "All checks passed." +else + echo "$FAILURES check(s) failed." + exit 1 +fi diff --git a/packaging/windows/configure-dsn.ps1 b/packaging/windows/configure-dsn.ps1 new file mode 100644 index 0000000..5da15d3 --- /dev/null +++ b/packaging/windows/configure-dsn.ps1 @@ -0,0 +1,743 @@ +<# +.SYNOPSIS + Create or edit a Stackable SQLite ODBC data source. + +.DESCRIPTION + Presents a dialog covering the driver's whole connection-string surface, + which for SQLite is one keyword, and writes the result as an ODBC data + source. + + The write goes through the installer's SQLConfigDataSource, which calls the + driver's own ConfigDSN entry point, rather than writing the registry + directly. That keeps the driver in the loop and inherits whatever validation + it performs. + + This is also what the ODBC Data Source Administrator's "Add..." and + "Configure..." buttons display. Those load the driver's setup DLL and ask + it for a dialog; the driver's Backend::configure_dsn hook runs this script + with -Emit and writes the keywords it returns. Run the script directly to + get the same dialog without going through the Administrator. + +.PARAMETER Dsn + Data source to edit. Omitted, the dialog starts empty. + +.PARAMETER System + Start on System scope (HKLM) rather than User (HKCU). Needs elevation. + +.PARAMETER NoGui + Write the data source from -Set without displaying a dialog. Intended for + scripted installs and for testing the write path. + +.PARAMETER Set + Key/value pairs for -NoGui, keyed by connection-string keyword. + +.PARAMETER Emit + Display the dialog and print the resulting keywords to stdout as JSON + instead of writing a data source. Reads the keywords to pre-fill from + stdin, also as JSON. This is the mode the driver's ConfigDSN hook uses: + the driver, not this script, performs the write. + + Exit codes are the channel for the verdict, because stdout carries the + payload: 0 accepted, 2 cancelled, anything else a failure whose reason is + on stderr. + +.EXAMPLE + .\configure-dsn.ps1 + +.EXAMPLE + .\configure-dsn.ps1 -Dsn sales_db + +.EXAMPLE + .\configure-dsn.ps1 -NoGui -Set @{ DSN='sales_db'; Database='C:\data\sales.db' } +#> +[CmdletBinding()] +param( + [string]$Dsn, + [switch]$System, + [switch]$NoGui, + [hashtable]$Set, + [switch]$Emit, + [string]$DriverName = 'stackable_odbc_sqlite' +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Field table +# --------------------------------------------------------------------------- +# The one place a connection-string keyword is named. Layout, the read path, +# the write path and validation are all generated from this, so adding a +# keyword is one entry here rather than an edit in four places. SQLite needs +# exactly one; the table is still a table so that a second one costs an entry +# rather than a rewrite, which is the shape the Trino driver grew into. +# +# Key the connection-string keyword, lower case, matching the PARAM_ +# constants in src/backend/types/connect_params.rs. +# Type Text | File +# +# `dsn_keys_match_the_connection_string_parser` in src/lib.rs fails the build +# if this list and the parser ever disagree. + +$script:Fields = @( + @{ Key='database'; Label='Database file'; Type='File'; Required=$true + Help='Path to the SQLite database file, or :memory: for a throwaway in-memory database. A file that does not exist yet is created on first connect.' } +) + +function Get-Field { param([string]$Key) $script:Fields | Where-Object { $_.Key -eq $Key } } + +function Get-FieldDefault { + param($Field) + if ($Field.Contains('Default')) { return $Field.Default } + return '' +} + +function ConvertTo-FieldValues { + <# + Normalise a caller-supplied keyword map onto the field table's own + keys: case folded, DSN lifted out as the name. + + Shared by -NoGui and -Emit, the two paths whose input comes from a + caller rather than from the dialog, and so the only two that can be + handed a keyword the table does not carry. + + Unknown keywords are kept aside in Extra rather than rejected. -Emit + receives a data source's whole stored section, which carries keywords + this dialog does not model (Driver, and anything written by hand), and + returning fewer keywords than arrived would delete them. + -NoGui rejects them instead: there the map is something a person just + typed, so an unrecognised keyword is far more likely a typo than a + keyword worth preserving, and silently ignoring it would write a data + source missing the setting they asked for. + #> + param([hashtable]$Set, [switch]$KeepUnknown) + + $values = @{} + $extra = @{} + $name = '' + foreach ($k in $Set.Keys) { + $lk = "$k".ToLowerInvariant() + if ($lk -eq 'dsn') { $name = "$($Set[$k])"; continue } + $f = Get-Field $lk + if (-not $f) { + if ($KeepUnknown) { $extra[$k] = "$($Set[$k])"; continue } + throw "Unknown connection-string keyword: $k" + } + $values[$f.Key] = "$($Set[$k])" + } + @{ Values = $values; Name = $name; Extra = $extra } +} + +# --------------------------------------------------------------------------- +# ODBC installer interop +# --------------------------------------------------------------------------- + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class OdbcInstaller { + // BOOL, so 4 bytes: the default bool marshalling is correct here. + [DllImport("odbccp32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool SQLConfigDataSourceW(IntPtr hwndParent, ushort fRequest, + string lpszDriver, string lpszAttributes); + + // RETCODE is SQLSMALLINT: 16 bits. Declaring this as bool reads the wrong + // width and loses the error record entirely. + [DllImport("odbccp32.dll", CharSet = CharSet.Unicode)] + public static extern short SQLInstallerErrorW(ushort iError, out int pfErrorCode, + StringBuilder lpszErrorMsg, ushort cbErrorMsgMax, out ushort pcbErrorMsg); + + [DllImport("odbccp32.dll", CharSet = CharSet.Unicode)] + public static extern int SQLGetPrivateProfileStringW(string lpszSection, string lpszEntry, + string lpszDefault, StringBuilder RetBuffer, int cbRetBuffer, string lpszFilename); + + [DllImport("odbccp32.dll")] + public static extern bool SQLSetConfigMode(ushort wConfigMode); +} +"@ -ErrorAction SilentlyContinue + +# ConfigDSN fRequest values, from odbcinst.h. +$script:ODBC_ADD_DSN = 1 +$script:ODBC_CONFIG_DSN = 2 +$script:ODBC_ADD_SYS_DSN = 4 +$script:ODBC_CONFIG_SYS_DSN = 5 +# SQLSetConfigMode values. +$script:ODBC_USER_DSN = 1 +$script:ODBC_SYSTEM_DSN = 2 + +function Test-Elevated { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal $id).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-InstallerErrors { + <# Drain the installer error buffer. Empty when the last call succeeded. #> + $out = @() + for ($i = 1; $i -le 8; $i++) { + $code = 0; $pcb = 0 + $sb = New-Object System.Text.StringBuilder 1024 + $rc = [OdbcInstaller]::SQLInstallerErrorW([uint16]$i, [ref]$code, $sb, [uint16]1024, [ref]$pcb) + # SQL_SUCCESS = 0, SQL_SUCCESS_WITH_INFO = 1; anything else ends the list. + if ($rc -ne 0 -and $rc -ne 1) { break } + $out += "[$code] $($sb.ToString())" + } + $out +} + +function Read-Dsn { + <# + Pre-fill from an existing data source. Returns a hashtable keyed by + connection-string keyword, holding only the keywords present. + #> + param([string]$Name, [bool]$IsSystem) + + $mode = if ($IsSystem) { $script:ODBC_SYSTEM_DSN } else { $script:ODBC_USER_DSN } + [void][OdbcInstaller]::SQLSetConfigMode([uint16]$mode) + + $values = @{} + foreach ($f in $script:Fields) { + $sb = New-Object System.Text.StringBuilder 4096 + $n = [OdbcInstaller]::SQLGetPrivateProfileStringW($Name, $f.Key, '', $sb, 4096, 'ODBC.INI') + if ($n -gt 0) { $values[$f.Key] = $sb.ToString() } + } + [void][OdbcInstaller]::SQLSetConfigMode(0) + $values +} + +function Get-ExistingDsnNames { + param([bool]$IsSystem) + $hive = if ($IsSystem) { 'HKLM:' } else { 'HKCU:' } + $path = "$hive\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" + if (-not (Test-Path $path)) { return @() } + $item = Get-Item $path + $item.GetValueNames() | Where-Object { $item.GetValue($_) -eq $DriverName } | Sort-Object +} + +function Write-Dsn { + <# Write the data source through the driver's own ConfigDSN. #> + param([hashtable]$Values, [string]$Name, [bool]$IsSystem, [bool]$Replace) + + $pairs = @("DSN=$Name") + foreach ($f in $script:Fields) { + if (-not $Values.Contains($f.Key)) { continue } + $v = $Values[$f.Key] + if ([string]::IsNullOrEmpty($v)) { continue } + $pairs += "$($f.Key)=$v" + } + # ConfigDSN takes a doubly null-terminated list of keyword-value pairs. + $attributes = ($pairs -join "`0") + "`0" + + $request = if ($IsSystem) { + if ($Replace) { $script:ODBC_CONFIG_SYS_DSN } else { $script:ODBC_ADD_SYS_DSN } + } else { + if ($Replace) { $script:ODBC_CONFIG_DSN } else { $script:ODBC_ADD_DSN } + } + + [void](Get-InstallerErrors) # clear anything stale before the call + $ok = [OdbcInstaller]::SQLConfigDataSourceW([IntPtr]::Zero, [uint16]$request, + $DriverName, $attributes) + if (-not $ok) { + # @() around the call: PowerShell unrolls an empty array return to + # $null, and Set-StrictMode makes .Count on it an error. + $errs = @(Get-InstallerErrors) + $detail = if ($errs.Count) { $errs -join "`r`n" } else { 'the installer reported no detail' } + throw "Writing the data source failed:`r`n$detail" + } +} + +function Build-ConnectionString { + <# + A DSN-less connection string for the Test button, so a configuration is + proved before it is written. + #> + param([hashtable]$Values) + + $parts = @("Driver=$DriverName") + foreach ($f in $script:Fields) { + if (-not $Values.Contains($f.Key)) { continue } + $v = $Values[$f.Key] + if ([string]::IsNullOrEmpty($v)) { continue } + $parts += "$($f.Key)=$v" + } + ($parts -join ';') + ';' +} + +function Test-DsnConnection { + param([hashtable]$Values) + + $cs = Build-ConnectionString $Values + $conn = New-Object System.Data.Odbc.OdbcConnection $cs + # Opening a SQLite file is local and immediate, so this bounds a hung + # network filesystem rather than a slow server. + $conn.ConnectionTimeout = 15 + try { + $conn.Open() + $cmd = $conn.CreateCommand() + # The version of the SQLite compiled into the driver, and the count of + # tables in the file. The count is what distinguishes "opened your + # database" from "created an empty file because the path was wrong", + # which is the mistake this button exists to catch: SQLite creates a + # missing file rather than refusing, so a typo in the path connects + # perfectly well and finds nothing. + $cmd.CommandText = + "SELECT sqlite_version(), (SELECT count(*) FROM sqlite_master WHERE type = 'table')" + $r = $cmd.ExecuteReader() + $facts = @() + $tables = $null + if ($r.Read()) { + $tables = "$($r[1])" + # Objects rather than two-element arrays: PowerShell flattens a + # nested array literal, so a list of pairs collapses into a list of + # strings and indexing a "pair" then indexes into a *string*. + $facts = @( + [PSCustomObject]@{ Name = 'Database'; Value = "$($Values['database'])" } + [PSCustomObject]@{ Name = 'SQLite'; Value = "$($r[0])" } + [PSCustomObject]@{ Name = 'Tables'; Value = $tables } + ) + } + $r.Close() + # Held in its own variable rather than read back out of $facts by + # index: an index into that list silently follows any reordering of it, + # and under Set-StrictMode an out-of-range one is a terminating error + # inside a handler WinForms would swallow. + $message = if ($tables -eq '0') { + 'Connected, but the database holds no tables.' + } else { + 'Connected.' + } + return @{ Ok = $true; Message = $message; Facts = $facts } + } catch { + return @{ Ok = $false; Message = $_.Exception.Message; Facts = @() } + } finally { + if ($conn.State -ne 'Closed') { $conn.Close() } + } +} + +function Show-ConnectionResult { + <# + Report a connection test. + + A success gets its own small form rather than a MessageBox, because the + facts are a two-column table and a MessageBox cannot align one: its + font is proportional, so padding a label with spaces lines nothing up. + A failure stays a MessageBox, because the driver's diagnostic is a + paragraph and not a table. + #> + param([hashtable]$Result) + + if (-not $Result.Ok) { + [void][System.Windows.Forms.MessageBox]::Show($Result.Message, + 'Connection failed', 'OK', 'Error') + return + } + + $dlg = New-Object System.Windows.Forms.Form + $dlg.Text = 'Connection succeeded' + $dlg.FormBorderStyle = 'FixedDialog' + $dlg.StartPosition = 'CenterScreen' + $dlg.MinimizeBox = $false + $dlg.MaximizeBox = $false + $dlg.ShowInTaskbar = $false + # Same reason the main dialog sets it under -Emit: this belongs to a + # separate process from the ODBC Administrator that is waiting on it. + $dlg.TopMost = [bool]$Emit + # The form sizes itself to the layout below. Positioning by hand from a + # panel's Right/Bottom does not work, because an AutoSize panel has not + # been measured yet at that point. That yields a window sized from stale + # bounds, invisible and modal, which locks its parent out of all input with + # nothing on screen to explain why. + $dlg.AutoSize = $true + $dlg.AutoSizeMode = 'GrowAndShrink' + $dlg.Padding = New-Object System.Windows.Forms.Padding(14) + + $root = New-Object System.Windows.Forms.TableLayoutPanel + $root.ColumnCount = 2 + $root.AutoSize = $true + $root.AutoSizeMode = 'GrowAndShrink' + $root.Dock = 'Fill' + + $icon = New-Object System.Windows.Forms.PictureBox + $icon.Image = [System.Drawing.SystemIcons]::Information.ToBitmap() + $icon.SizeMode = 'AutoSize' + $icon.Margin = New-Object System.Windows.Forms.Padding(4, 4, 14, 8) + $root.Controls.Add($icon, 0, 0) + + $head = New-Object System.Windows.Forms.Label + $head.Text = $Result.Message + $head.Font = New-Object System.Drawing.Font($dlg.Font, [System.Drawing.FontStyle]::Bold) + $head.AutoSize = $true + $head.Margin = New-Object System.Windows.Forms.Padding(0, 8, 0, 10) + $root.Controls.Add($head, 1, 0) + + # Two columns, so the values share a left edge whatever the labels measure. + $grid = New-Object System.Windows.Forms.TableLayoutPanel + $grid.ColumnCount = 2 + $grid.AutoSize = $true + $grid.AutoSizeMode = 'GrowAndShrink' + $grid.Margin = New-Object System.Windows.Forms.Padding(0) + foreach ($f in $Result.Facts) { + $k = New-Object System.Windows.Forms.Label + $k.Text = "$($f.Name):" + $k.AutoSize = $true + $k.Margin = New-Object System.Windows.Forms.Padding(0, 3, 16, 3) + $v = New-Object System.Windows.Forms.Label + $v.Text = $f.Value + $v.AutoSize = $true + $v.Margin = New-Object System.Windows.Forms.Padding(0, 3, 0, 3) + $grid.Controls.Add($k) + $grid.Controls.Add($v) + } + $root.Controls.Add($grid, 1, 1) + + $ok = New-Object System.Windows.Forms.Button + $ok.Text = 'OK' + $ok.Size = New-Object System.Drawing.Size(90, 28) + $ok.Anchor = 'Right' + $ok.Margin = New-Object System.Windows.Forms.Padding(0, 16, 0, 0) + $ok.DialogResult = [System.Windows.Forms.DialogResult]::OK + $root.Controls.Add($ok, 1, 2) + + $dlg.Controls.Add($root) + $dlg.AcceptButton = $ok + $dlg.CancelButton = $ok + + [void]$dlg.ShowDialog() + $dlg.Dispose() +} + +function Test-Values { + <# + Only the rules that are cheap and certain here. Everything else is left + to the driver, which is the authority and reports through + SQLGetDiagRec; duplicating its rules would let the two disagree. + + In particular the database path is *not* checked for existence. SQLite + creates a missing file on first connect, so a path that does not exist + yet is a legitimate way to make a new database, and refusing it here + would forbid something the driver permits. + #> + param([hashtable]$Values, [string]$Name) + + $problems = @() + if ([string]::IsNullOrWhiteSpace($Name)) { $problems += 'A data source name is required.' } + foreach ($f in $script:Fields | Where-Object { $_.Contains('Required') -and $_.Required }) { + if (-not $Values.Contains($f.Key) -or [string]::IsNullOrWhiteSpace($Values[$f.Key])) { + $problems += "$($f.Label) is required." + } + } + $problems +} + +# --------------------------------------------------------------------------- +# Headless path +# --------------------------------------------------------------------------- + +if ($NoGui) { + if (-not $Set) { throw '-NoGui requires -Set.' } + + $parsed = ConvertTo-FieldValues $Set + $values = $parsed.Values + $name = if ($parsed.Name) { $parsed.Name } else { $Dsn } + + $problems = @(Test-Values $values $name) + if ($problems.Count) { throw ($problems -join "`r`n") } + + if ($System -and -not (Test-Elevated)) { + throw 'A System data source needs an elevated session. Run as Administrator, or omit -System.' + } + $exists = @(Get-ExistingDsnNames ([bool]$System)) -contains $name + Write-Dsn $values $name ([bool]$System) $exists + $scope = if ($System) { 'System' } else { 'User' } + Write-Output "$scope data source '$name' written." + return +} + +# --------------------------------------------------------------------------- +# Emit mode input +# --------------------------------------------------------------------------- +# The keywords to pre-fill arrive on stdin as a JSON object. A pipe rather than +# a file because a Configure... payload is the data source's whole stored +# section, and this script does not get to assume every keyword in it is one +# this driver models and therefore harmless on disk. + +$script:EmitExtra = @{} +$script:EmitPrefill = @{} +$script:EmitValues = @{} +$script:EmitNameFixed = $false + +if ($Emit) { + $stdin = [Console]::In.ReadToEnd() + $incoming = @{} + if (-not [string]::IsNullOrWhiteSpace($stdin)) { + $json = $stdin | ConvertFrom-Json + foreach ($p in $json.PSObject.Properties) { $incoming[$p.Name] = "$($p.Value)" } + } + + $parsed = ConvertTo-FieldValues $incoming -KeepUnknown + $script:EmitExtra = $parsed.Extra + $script:EmitPrefill = $parsed.Values + if ($parsed.Name) { + $Dsn = $parsed.Name + # The spec: "if a data source name was passed to it, ConfigDSN displays + # that name but does not allow the user to change it." The driver's + # core enforces this on the map coming back, so an editable box here + # would only produce a failed call. + $script:EmitNameFixed = $true + } +} + +# --------------------------------------------------------------------------- +# Dialog +# --------------------------------------------------------------------------- + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() + +$form = New-Object System.Windows.Forms.Form +$form.Text = 'Stackable SQLite ODBC - Data Source' +# Tall enough for the header row, one field row, the button row and the hint +# beneath it, with the title bar and border taken off the top. Grown rather +# than fitted exactly: a larger system font pushes every row down, and a +# clipped hint is worse than a little empty space. +$form.Size = New-Object System.Drawing.Size(620, 260) +$form.StartPosition = 'CenterScreen' +$form.FormBorderStyle = 'FixedDialog' +$form.MaximizeBox = $false +# The ODBC Administrator owns the foreground while it waits on ConfigDSN, and +# this dialog belongs to a separate process, so without this it opens behind +# the window that asked for it. +$form.TopMost = [bool]$Emit + +$tip = New-Object System.Windows.Forms.ToolTip +$tip.AutoPopDelay = 20000 + +# --- header: name and scope --- +$lblName = New-Object System.Windows.Forms.Label +$lblName.Text = 'Data source name' +$lblName.Location = New-Object System.Drawing.Point(12, 15) +$lblName.Size = New-Object System.Drawing.Size(130, 20) +$form.Controls.Add($lblName) + +$txtName = New-Object System.Windows.Forms.TextBox +$txtName.Location = New-Object System.Drawing.Point(148, 12) +$txtName.Size = New-Object System.Drawing.Size(200, 22) +$form.Controls.Add($txtName) + +$rbUser = New-Object System.Windows.Forms.RadioButton +$rbUser.Text = 'User' +$rbUser.Location = New-Object System.Drawing.Point(370, 11) +$rbUser.Size = New-Object System.Drawing.Size(60, 24) +$rbUser.Checked = -not $System +$form.Controls.Add($rbUser) + +$rbSystem = New-Object System.Windows.Forms.RadioButton +$rbSystem.Text = 'System' +$rbSystem.Location = New-Object System.Drawing.Point(434, 11) +$rbSystem.Size = New-Object System.Drawing.Size(80, 24) +$rbSystem.Checked = [bool]$System +$form.Controls.Add($rbSystem) + +$lblElev = New-Object System.Windows.Forms.Label +$lblElev.Location = New-Object System.Drawing.Point(370, 36) +$lblElev.Size = New-Object System.Drawing.Size(220, 18) +$lblElev.ForeColor = [System.Drawing.Color]::FromArgb(160, 90, 0) +if (-not (Test-Elevated)) { + $lblElev.Text = 'System needs an elevated session' + $rbSystem.Enabled = $false + if ($System) { $rbUser.Checked = $true } +} +$form.Controls.Add($lblElev) + +# Under -Emit the driver performs the write, and the Administrator has already +# chosen the scope and set the installer's config mode accordingly. Offering a +# choice the dialog cannot honour would be a lie, so the radios go away. +if ($Emit) { + $rbUser.Visible = $false + $rbSystem.Visible = $false + $lblElev.Visible = $false +} +if ($script:EmitNameFixed) { $txtName.ReadOnly = $true } + +# --- fields, built from the field table --- +# A flat panel rather than the Trino driver's TabControl: one keyword does not +# need tabs, and the loop is the same shape either way if a second arrives. +$panel = New-Object System.Windows.Forms.Panel +$panel.Location = New-Object System.Drawing.Point(12, 62) +$panel.Size = New-Object System.Drawing.Size(580, 70) +$form.Controls.Add($panel) + +$script:Controls = @{} + +$y = 6 +foreach ($f in $script:Fields) { + $label = New-Object System.Windows.Forms.Label + $label.Text = $f.Label + $label.Location = New-Object System.Drawing.Point(0, ($y + 3)) + $label.Size = New-Object System.Drawing.Size(130, 20) + $panel.Controls.Add($label) + + $ctl = New-Object System.Windows.Forms.TextBox + $ctl.Location = New-Object System.Drawing.Point(136, $y) + $ctl.Text = (Get-FieldDefault $f) + + if ($f.Type -eq 'File') { + $ctl.Size = New-Object System.Drawing.Size(340, 22) + $browse = New-Object System.Windows.Forms.Button + $browse.Text = 'Browse...' + $browse.Location = New-Object System.Drawing.Point(482, ($y - 1)) + $browse.Size = New-Object System.Drawing.Size(90, 24) + $target = $ctl + $browse.Add_Click({ + $dlg = New-Object System.Windows.Forms.OpenFileDialog + $dlg.Filter = 'SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*' + # SQLite creates a database that is not there yet, so naming one is + # how a new database is made. The default would refuse the name and + # send the user off to create an empty file by hand first. + $dlg.CheckFileExists = $false + if ($dlg.ShowDialog() -eq 'OK') { $target.Text = $dlg.FileName } + }.GetNewClosure()) + $panel.Controls.Add($browse) + } else { + $ctl.Size = New-Object System.Drawing.Size(436, 22) + } + + if ($f.Contains('Help')) { $tip.SetToolTip($ctl, $f.Help) } + $panel.Controls.Add($ctl) + $script:Controls[$f.Key] = $ctl + $y += 30 +} + +function Get-FormValues { + $values = @{} + foreach ($f in $script:Fields) { + $v = $script:Controls[$f.Key].Text + if (-not [string]::IsNullOrEmpty($v)) { $values[$f.Key] = $v } + } + $values +} + +function Set-FormValues { + param([hashtable]$Values) + foreach ($f in $script:Fields) { + if (-not $Values.Contains($f.Key)) { continue } + $script:Controls[$f.Key].Text = $Values[$f.Key] + } +} + +# --- buttons --- +$lblHint = New-Object System.Windows.Forms.Label +$lblHint.Text = 'A file that does not exist yet is created on first connect.' +$lblHint.Location = New-Object System.Drawing.Point(12, 176) +$lblHint.Size = New-Object System.Drawing.Size(580, 18) +$lblHint.ForeColor = [System.Drawing.Color]::FromArgb(110, 110, 110) +$form.Controls.Add($lblHint) + +$btnTest = New-Object System.Windows.Forms.Button +$btnTest.Text = 'Test connection' +$btnTest.Location = New-Object System.Drawing.Point(12, 140) +$btnTest.Size = New-Object System.Drawing.Size(130, 30) +$btnTest.Add_Click({ + $values = Get-FormValues + $problems = @(Test-Values $values $txtName.Text) + if ($problems.Count) { + [void][System.Windows.Forms.MessageBox]::Show(($problems -join "`r`n"), + 'Incomplete', 'OK', 'Warning') + return + } + $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $btnTest.Enabled = $false + # One catch around the whole thing: WinForms swallows an exception thrown + # from a handler, so anything uncaught here leaves the button looking as + # though it did nothing at all. + try { + try { $result = Test-DsnConnection $values } + finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default; $btnTest.Enabled = $true } + Show-ConnectionResult $result + } catch { + [void][System.Windows.Forms.MessageBox]::Show($_.Exception.ToString(), + 'Could not test the connection', 'OK', 'Error') + } +}) +$form.Controls.Add($btnTest) + +$btnOk = New-Object System.Windows.Forms.Button +$btnOk.Text = 'OK' +$btnOk.Location = New-Object System.Drawing.Point(406, 140) +$btnOk.Size = New-Object System.Drawing.Size(90, 30) +$btnOk.Add_Click({ + $values = Get-FormValues + $problems = @(Test-Values $values $txtName.Text) + if ($problems.Count) { + [void][System.Windows.Forms.MessageBox]::Show(($problems -join "`r`n"), + 'Incomplete', 'OK', 'Warning') + return + } + if (-not $Emit) { + $isSystem = $rbSystem.Checked + $exists = @(Get-ExistingDsnNames $isSystem) -contains $txtName.Text + try { + Write-Dsn $values $txtName.Text $isSystem $exists + } catch { + [void][System.Windows.Forms.MessageBox]::Show($_.Exception.Message, + 'Could not write the data source', 'OK', 'Error') + return + } + } + # The handler is a scriptblock with its own scope, and -Emit needs these + # after ShowDialog returns. + $script:EmitValues = $values + $form.DialogResult = [System.Windows.Forms.DialogResult]::OK + $form.Close() +}) +$form.Controls.Add($btnOk) + +$btnCancel = New-Object System.Windows.Forms.Button +$btnCancel.Text = 'Cancel' +$btnCancel.Location = New-Object System.Drawing.Point(502, 140) +$btnCancel.Size = New-Object System.Drawing.Size(90, 30) +$btnCancel.Add_Click({ $form.DialogResult = [System.Windows.Forms.DialogResult]::Cancel; $form.Close() }) +$form.Controls.Add($btnCancel) +$form.CancelButton = $btnCancel + +# --- pre-fill when editing --- +if ($Dsn) { $txtName.Text = $Dsn } +if ($Emit) { + # The driver has already merged the data source's stored keywords in, so + # reading ODBC.INI again here would only be able to disagree with it. + if ($script:EmitPrefill.Count) { Set-FormValues $script:EmitPrefill } +} elseif ($Dsn) { + $existing = Read-Dsn $Dsn ([bool]$System) + if ($existing.Count) { Set-FormValues $existing } +} + +$result = $form.ShowDialog() + +if (-not $Emit) { + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + $scope = if ($rbSystem.Checked) { 'System' } else { 'User' } + Write-Output "$scope data source '$($txtName.Text)' written." + } + return +} + +# --- emit mode: the verdict is the exit code, the payload is stdout --- +if ($result -ne [System.Windows.Forms.DialogResult]::OK) { + # Cancelled. The driver returns Ok(None) and ConfigDSN posts no installer + # error, because nothing failed. + exit 2 +} + +$out = [ordered]@{ DSN = $txtName.Text } +# Keywords the dialog does not model are returned exactly as they arrived. On +# a Configure... this is the whole rest of the data source's section, and +# dropping them would delete settings the user never touched. +foreach ($k in $script:EmitExtra.Keys) { $out[$k] = $script:EmitExtra[$k] } +foreach ($f in $script:Fields) { + if ($script:EmitValues.Contains($f.Key)) { $out[$f.Key] = $script:EmitValues[$f.Key] } +} +[Console]::Out.Write(($out | ConvertTo-Json -Compress -Depth 3)) +exit 0 diff --git a/packaging/windows/install.bat b/packaging/windows/install.bat index 6ff6de7..5ab929a 100644 --- a/packaging/windows/install.bat +++ b/packaging/windows/install.bat @@ -11,6 +11,16 @@ if not exist "%~dp0%DRIVER_DLL%" ( exit /b 1 ) +rem The driver's ConfigDSN runs this script to display its setup dialog, so the +rem ODBC Data Source Administrator's "Add..." button needs it installed +rem alongside the DLL. Checked here rather than after the copy so a missing +rem file is reported before the driver is registered. +if not exist "%~dp0configure-dsn.ps1" ( + echo ERROR: configure-dsn.ps1 not found next to install.bat. + echo The driver needs it for the ODBC Administrator's "Add..." dialog. + exit /b 1 +) + if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%" copy /Y "%~dp0%DRIVER_DLL%" "%INSTALL_DIR%\" >nul @@ -25,8 +35,17 @@ if errorlevel 1 ( exit /b 1 ) +copy /Y "%~dp0configure-dsn.ps1" "%INSTALL_DIR%\" >nul +if errorlevel 1 ( + echo ERROR: Failed to copy configure-dsn.ps1. + exit /b 1 +) + echo Stackable SQLite ODBC driver installed to %INSTALL_DIR%. echo Verify with: ODBC Data Source Administrator (odbcad32.exe) echo. -echo To create a DSN (optional), see README.md. +echo To create a DSN, use the ODBC Data Source Administrator's "Add..." button, +echo or run the same dialog directly: +echo powershell -ExecutionPolicy Bypass -File "%INSTALL_DIR%\configure-dsn.ps1" +echo See README.md for the odbcconf and registry alternatives. endlocal diff --git a/packaging/windows/uninstall.bat b/packaging/windows/uninstall.bat index 89aede7..fe3641c 100644 --- a/packaging/windows/uninstall.bat +++ b/packaging/windows/uninstall.bat @@ -12,9 +12,12 @@ reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\%DRIVER_NAME%" /f >nul 2>&1 reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "%DRIVER_NAME%" /f >nul 2>&1 if exist "%INSTALL_DIR%\%DRIVER_DLL%" del /F /Q "%INSTALL_DIR%\%DRIVER_DLL%" +if exist "%INSTALL_DIR%\configure-dsn.ps1" del /F /Q "%INSTALL_DIR%\configure-dsn.ps1" echo Stackable SQLite ODBC driver uninstalled. echo. +echo Your database files are untouched: a data source only points at one. +echo. echo If you created any DSNs, remove them with: echo reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\YourDsnName" /f echo reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "YourDsnName" /f diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..41d5797 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>stackabletech/.github:renovate-config" + ] +} diff --git a/src/backend.rs b/src/backend.rs index c7253f8..b5f497b 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,5 +1,6 @@ use std::{ borrow::Cow, + collections::HashMap, sync::{Arc, Mutex}, }; @@ -7,6 +8,7 @@ use snafu::Snafu; use stackable_odbc_core::{ backend::Backend, errors::OdbcError, + setup::{ConfigRequest, SetupError}, types::{ ColumnDescriptor, ColumnRow, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, ForeignKeyRow, InfoValue, PrimaryKeyRow, SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, @@ -28,6 +30,7 @@ pub(crate) mod info; mod info; mod metadata; mod params; +mod setup; mod types; /// The SQLite [`Backend`] implementation. @@ -405,6 +408,20 @@ impl Backend for SqliteBackend { type Error = SqliteError; type Statement = SqliteStatement; + /// The DSN setup dialog the ODBC Administrator's **Add…** and + /// **Configure…** buttons display. + /// + /// See the `backend::setup` module for how it is presented, and why the + /// dialog is `packaging/windows/configure-dsn.ps1` rather than a second + /// implementation in Rust. + fn configure_dsn( + hwnd_parent: *mut std::ffi::c_void, + request: ConfigRequest, + attributes: HashMap<String, String>, + ) -> Result<Option<HashMap<String, String>>, SetupError> { + setup::configure_dsn(hwnd_parent, request, attributes) + } + /// Hand out the connection's interrupt handle. Infallible and lock-free: /// the handle was captured in [`SqliteBackend::connect`], so this only /// bumps a refcount (see `SqliteConnection::interrupt`). Not an intra-doc diff --git a/src/backend/execute.rs b/src/backend/execute.rs index cb516e7..2534406 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -196,7 +196,8 @@ pub(super) fn exec_direct( Ok(SqliteStatement::new(columns, rows)) } -/// Validate and store a SQL statement for later execution via [`Backend::execute`]. +/// Validate and store a SQL statement for later execution via +/// [`Backend::execute`](stackable_odbc_core::backend::Backend::execute). /// /// The SQL is parsed by rusqlite to detect syntax errors early (at prepare time, /// matching ODBC semantics). The validated SQL is stored in the returned diff --git a/src/backend/info.rs b/src/backend/info.rs index c8fed40..0ab0ca0 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -593,7 +593,7 @@ pub(crate) const SQLITE_UNION: u32 = SQL_U_UNION | SQL_U_UNION_ALL; /// /// Just `$`. SQLite's tokenizer classifies it as an identifier character, so a /// name containing it parses unquoted and round-trips through `sqlite_master` -/// unchanged. Every character in [`SPECIAL_CHARACTER_CANDIDATES`] is executed +/// unchanged. Every character in `SPECIAL_CHARACTER_CANDIDATES` is executed /// against the bundled library by /// `special_characters_are_each_live_probed`, which checks the rejected ones /// too. diff --git a/src/backend/setup.rs b/src/backend/setup.rs new file mode 100644 index 0000000..ef47ec0 --- /dev/null +++ b/src/backend/setup.rs @@ -0,0 +1,432 @@ +//! The driver's DSN setup dialog, behind +//! [`Backend::configure_dsn`](stackable_odbc_core::backend::Backend::configure_dsn). +//! +//! Core owns all of `ConfigDSN`: validating *fRequest*, rejecting `DRIVER=`, +//! merging the data source's existing keywords in, calling `SQLValidDSN` and +//! writing through `SQLWriteDSNToIni`. This module supplies the one thing that +//! varies per driver, which is asking a person which keywords the data source +//! needs. +//! +//! The dialog itself is `packaging/windows/configure-dsn.ps1`, run with +//! `-Emit`, which prints the keywords it collected instead of writing them. +//! Reusing the script is what keeps one list of keywords: its `$Fields` table +//! names them, and `dsn_keys_match_the_connection_string_parser` in +//! `src/lib.rs` fails the build if that table and the parser disagree. A second +//! dialog written in Rust would be a second list, checked by nothing. +//! +//! Only the two OS calls are `#[cfg(windows)]`; every decision is a plain +//! function with unit tests that run on Linux. + +use std::collections::HashMap; + +use stackable_odbc_core::setup::{ConfigRequest, SetupError}; + +/// The dialog script, looked for beside the driver's own DLL. +/// +/// `install.bat` must copy it there, as a hard requirement rather than +/// best-effort: without it the Administrator's **Add…** and **Configure…** +/// buttons have no dialog to run, and every such request fails. +/// +/// The `cfg_attr`s below, here and on the three functions that follow, are +/// core's own idiom for the parts of `ConfigDSN` that only Windows reaches: +/// they stay compiled and unit-tested everywhere, so a change breaks the build +/// on the platform this is developed on rather than on the one it ships to. +#[cfg_attr(not(windows), allow(dead_code))] +const DIALOG_SCRIPT: &str = "configure-dsn.ps1"; + +/// The dialog collected keywords: its stdout is the JSON map. +#[cfg_attr(not(windows), allow(dead_code))] +const EXIT_ACCEPTED: i32 = 0; +/// The user cancelled. Not a failure, so `ConfigDSN` posts no installer error. +#[cfg_attr(not(windows), allow(dead_code))] +const EXIT_CANCELLED: i32 = 2; + +/// Whether this call is allowed to put a dialog on the screen. +/// +/// Two things say no: +/// +/// - **A null `hwndParent`.** The spec is explicit: "The function will not +/// display any dialog boxes if the handle is null." It is also what keeps +/// this from recursing. `configure-dsn.ps1`, run standalone, writes its data +/// source through `SQLConfigDataSourceW` with a null *hwndParent*, which +/// re-enters this hook. This rule makes that re-entry headless, so the +/// script is not asked to launch itself. +/// - **`Remove`.** The Administrator has already asked the user to confirm the +/// deletion, and this driver keeps nothing outside `ODBC.INI` that a removal +/// would need to clean up. Deleting the database file is emphatically not +/// this hook's business: the data source is a pointer to a file the user +/// owns, and removing the pointer does not remove the file. +/// +/// `Add` and `Config` prompt. Everything else passes the attributes through +/// unchanged, which is exactly core's defaulted behaviour. +fn dialog_needed(hwnd_is_null: bool, request: ConfigRequest) -> bool { + if hwnd_is_null { + return false; + } + match request { + ConfigRequest::Add | ConfigRequest::Config => true, + ConfigRequest::Remove => false, + } +} + +/// The attribute map, as the dialog reads it on stdin. +/// +/// A pipe rather than a temp file. This driver's one keyword is a filesystem +/// path rather than a secret, so nothing here is confidential, but a temp file +/// would still put the path somewhere with a name any other process on the +/// machine can guess, and the pipe costs nothing over it. +#[cfg_attr(not(windows), allow(dead_code))] +fn encode_attributes(attributes: &HashMap<String, String>) -> Result<String, SetupError> { + serde_json::to_string(attributes).map_err(|e| { + // No value in the message: it goes to the installer error buffer and + // the ODBC Administrator displays it. + SetupError::request_failed(format!( + "could not encode the data source's keywords for the setup dialog: {e}" + )) + }) +} + +/// What the dialog decided, read back from its exit code and stdout. +/// +/// The exit code carries the verdict because stdout carries the payload. A +/// dialog cannot report "cancelled" in-band without inventing a sentinel that +/// some future keyword value could collide with. +#[cfg_attr(not(windows), allow(dead_code))] +fn interpret_outcome( + code: Option<i32>, + stdout: &str, + stderr: &str, +) -> Result<Option<HashMap<String, String>>, SetupError> { + match code { + Some(EXIT_ACCEPTED) => { + let attrs: HashMap<String, String> = + serde_json::from_str(stdout.trim()).map_err(|e| { + SetupError::request_failed(format!( + "the setup dialog returned something that is not a keyword list: {e}" + )) + })?; + Ok(Some(attrs)) + } + Some(EXIT_CANCELLED) => Ok(None), + other => { + // PowerShell writes a terminating error to stderr and exits 1. Pass + // it on: it is the only account of what went wrong, and without it + // the Administrator shows a bare "could not perform the operation". + let detail = stderr.trim(); + let detail = if detail.is_empty() { + "it reported no reason".to_string() + } else { + detail.to_string() + }; + let how = match other { + Some(c) => format!("exited with code {c}"), + None => "was terminated by a signal".to_string(), + }; + Err(SetupError::request_failed(format!( + "the setup dialog {how}: {detail}" + ))) + } + } +} + +/// Present the dialog and return the keywords it collected. +/// +/// See [`Backend::configure_dsn`](stackable_odbc_core::backend::Backend::configure_dsn) +/// for the contract this satisfies. +pub(super) fn configure_dsn( + hwnd_parent: *mut std::ffi::c_void, + request: ConfigRequest, + attributes: HashMap<String, String>, +) -> Result<Option<HashMap<String, String>>, SetupError> { + // Keyword count only, never values: a database path is not a secret, but + // this map is whatever the caller supplied and this driver does not get to + // decide that every keyword in it is safe to log. + tracing::debug!( + ?request, + headless = hwnd_parent.is_null(), + keywords = attributes.len(), + "SqliteBackend::configure_dsn" + ); + + if !dialog_needed(hwnd_parent.is_null(), request) { + return Ok(Some(attributes)); + } + + #[cfg(windows)] + { + let payload = encode_attributes(&attributes)?; + let (code, stdout, stderr) = windows::run_dialog(&payload)?; + interpret_outcome(code, &stdout, &stderr) + } + #[cfg(not(windows))] + { + // `ConfigDSNW` is a Windows export and core does not build it + // elsewhere, so this is unreachable rather than a gap. Passing the + // attributes through is what the caller would have got anyway. + tracing::warn!( + "ConfigDSN asked for a setup dialog, which this driver only has on \ + Windows; proceeding with the keywords as supplied" + ); + Ok(Some(attributes)) + } +} + +#[cfg(windows)] +mod windows { + //! Finding the dialog and running it. The only two OS calls in the module. + + use std::ffi::{OsString, c_void}; + use std::io::Write as _; + use std::os::windows::ffi::OsStringExt as _; + use std::os::windows::process::CommandExt as _; + use std::path::PathBuf; + use std::process::{Command, Stdio}; + + use stackable_odbc_core::setup::SetupError; + + use super::DIALOG_SCRIPT; + + /// `GetModuleHandleExW` flags, from `libloaderapi.h`. Together they mean + /// "the module containing this address, without taking a reference". + /// Taking a reference here would pin the driver DLL in the + /// Administrator's process for its lifetime. + const GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT: u32 = 0x0000_0002; + const GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS: u32 = 0x0000_0004; + + /// `CreateProcess`'s flag from `winbase.h`, so PowerShell does not flash a + /// console window over the Administrator for as long as the dialog is up. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + // Two functions from kernel32, which is the operating system rather than a + // dependency, the same reason the Windows SBOM declares no import of it. + // Declaring them here rather than taking `windows-sys` keeps the driver's + // dependency graph, and so its SBOM, unchanged by a setup dialog. + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetModuleHandleExW( + dw_flags: u32, + lp_module_name: *const u16, + ph_module: *mut *mut c_void, + ) -> i32; + fn GetModuleFileNameW(h_module: *mut c_void, lp_filename: *mut u16, n_size: u32) -> u32; + } + + /// The directory holding this DLL, identified from an address inside the + /// module. This function's own address is one. + /// + /// `std::env::current_exe()` answers with the Administrator's path, since + /// `ConfigDSN` runs inside `odbcad32.exe`. + fn driver_directory() -> Result<PathBuf, SetupError> { + let mut module: *mut c_void = std::ptr::null_mut(); + // SAFETY: the flags are the documented pair for an address lookup, the + // address is this function's own and so certainly inside the module, + // and `module` is a live out-pointer for the duration of the call. + let ok = unsafe { + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS + | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + driver_directory as *const u16, + &raw mut module, + ) + }; + if ok == 0 { + return Err(SetupError::request_failed( + "could not identify the driver DLL's own module".to_string(), + )); + } + + // MAX_PATH is not a limit on a path, only on the buffer most callers + // pass, so grow until the name fits rather than truncating it. A + // truncated path would name a directory that does not exist, and the + // failure would read as a missing script. + let mut buf = vec![0u16; 260]; + loop { + // SAFETY: `buf` is a live allocation of `buf.len()` u16s, which is + // exactly what the length argument claims. + let len = unsafe { GetModuleFileNameW(module, buf.as_mut_ptr(), buf.len() as u32) }; + if len == 0 { + return Err(SetupError::request_failed( + "could not read the driver DLL's own path".to_string(), + )); + } + if (len as usize) < buf.len() { + buf.truncate(len as usize); + break; + } + buf.resize(buf.len() * 2, 0); + } + + let path = PathBuf::from(OsString::from_wide(&buf)); + path.parent().map(PathBuf::from).ok_or_else(|| { + SetupError::request_failed(format!( + "the driver DLL's path has no directory: {}", + path.display() + )) + }) + } + + /// Run the dialog, feeding it `payload` on stdin. + /// + /// Returns the exit code and both output streams; deciding what they mean + /// is [`super::interpret_outcome`]'s job. + pub(super) fn run_dialog(payload: &str) -> Result<(Option<i32>, String, String), SetupError> { + let script = driver_directory()?.join(DIALOG_SCRIPT); + if !script.is_file() { + // Naming the path is the whole value of this error: the usual + // cause is a DLL registered from wherever it was unzipped, with + // the rest of the archive left behind. + return Err(SetupError::request_failed(format!( + "the setup dialog {DIALOG_SCRIPT} was not found beside the driver \ + (looked for {}). Reinstall with install.bat, which places both together.", + script.display() + ))); + } + + let mut child = Command::new("powershell.exe") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&script) + .arg("-Emit") + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + SetupError::request_failed(format!("could not run the setup dialog: {e}")) + })?; + + // Scoped so the pipe closes before the wait below. PowerShell's + // `[Console]::In.ReadToEnd()` does not return until it does, and this + // process does not read stdout until the wait, so leaving it open + // deadlocks both sides. + { + let mut stdin = child.stdin.take().ok_or_else(|| { + SetupError::request_failed("the setup dialog's stdin was not available".to_string()) + })?; + stdin.write_all(payload.as_bytes()).map_err(|e| { + SetupError::request_failed(format!( + "could not send the data source's keywords to the setup dialog: {e}" + )) + })?; + } + + let out = child.wait_with_output().map_err(|e| { + SetupError::request_failed(format!("the setup dialog could not be waited on: {e}")) + })?; + + Ok(( + out.status.code(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A null `hwndParent` must never prompt, for every request. The spec says + /// so, and it is also what stops `configure-dsn.ps1`'s own + /// `SQLConfigDataSourceW` write, which passes a null handle, from + /// re-entering this hook and launching the script a second time. + #[test] + fn a_null_parent_window_never_prompts() { + for request in [ + ConfigRequest::Add, + ConfigRequest::Config, + ConfigRequest::Remove, + ] { + assert!( + !dialog_needed(true, request), + "{request:?} with a null hwndParent must not display a dialog" + ); + } + } + + /// Add and Configure prompt; Remove does not. The Administrator confirms a + /// removal itself, and this driver has nothing outside `ODBC.INI` to clean. + #[test] + fn only_add_and_config_prompt() { + assert!(dialog_needed(false, ConfigRequest::Add)); + assert!(dialog_needed(false, ConfigRequest::Config)); + assert!(!dialog_needed(false, ConfigRequest::Remove)); + } + + #[test] + fn attributes_survive_the_exchange() { + let mut attrs = HashMap::new(); + attrs.insert("DSN".to_string(), "sqlite_local".to_string()); + // A Windows path, which is the value this driver actually carries and + // the reason the exchange is JSON: every separator in it is a + // backslash, which is an escape character in JSON and in a great many + // other framings, so a round trip that survives this survives the + // realistic case. + attrs.insert( + "database".to_string(), + r#"C:\Users\Analyst\My "Data"\sales.db"#.to_string(), + ); + + let encoded = encode_attributes(&attrs).expect("a string map encodes"); + let decoded = interpret_outcome(Some(EXIT_ACCEPTED), &encoded, "") + .expect("exit 0 with a keyword list is an acceptance") + .expect("an acceptance carries a map"); + assert_eq!(decoded, attrs); + } + + /// Cancelling is `Ok(None)`, which core turns into FALSE with no installer + /// error posted. An `Err` here would put "could not perform the operation" + /// in front of a user who changed their mind. + #[test] + fn cancelling_is_not_a_failure() { + let outcome = interpret_outcome(Some(EXIT_CANCELLED), "", "") + .expect("a cancelled dialog is not an error"); + assert_eq!(outcome, None); + } + + /// PowerShell exits 1 on a terminating error, having written the reason to + /// stderr. That reason is the only account of what went wrong, so it has to + /// reach the message core posts. + #[test] + fn a_failing_dialog_reports_its_stderr() { + let err = interpret_outcome(Some(1), "", "Set-StrictMode: variable is not set\n") + .expect_err("a non-zero, non-cancel exit is a failure"); + assert!( + err.message.contains("variable is not set"), + "the dialog's own reason must survive into the installer error: {}", + err.message + ); + assert!( + err.message.contains("exited with code 1"), + "the exit code belongs in the message too: {}", + err.message + ); + } + + /// A dialog that exits 0 but prints something else is a failure, not an + /// empty data source. Accepting it would write a data source with no + /// keywords at all, which fails much later and much less clearly. + #[test] + fn an_unreadable_reply_is_a_failure() { + let err = interpret_outcome(Some(EXIT_ACCEPTED), "not json at all", "") + .expect_err("a reply that is not a keyword list cannot be written"); + assert!( + err.message.contains("not a keyword list"), + "unexpected message: {}", + err.message + ); + } + + /// A dialog killed by a signal has no exit code, and must not be mistaken + /// for either an acceptance or a cancellation. + #[test] + fn a_killed_dialog_is_a_failure() { + let err = + interpret_outcome(None, "", "").expect_err("no exit code cannot be read as a verdict"); + assert!( + err.message.contains("terminated by a signal"), + "unexpected message: {}", + err.message + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1889c5d..250b5fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,3 +24,135 @@ stackable_odbc_core::forward_ffi!(crate::backend::SqliteBackend); #[cfg(test)] mod ffi_integration_tests; + +#[cfg(test)] +mod packaging_tests { + //! Checks that the shipped Windows setup dialog and the connection-string + //! parser describe the same driver. + //! + //! The dialog is PowerShell and nothing compiles it, so a keyword added to + //! one side and not the other would otherwise surface as a box a user can + //! fill in that the driver then ignores, or as a setting reachable only by + //! hand-editing the registry. + + /// Every connection-string keyword the parser accepts, from the `PARAM_` + /// constants themselves rather than a transcribed list. + fn connection_string_keys() -> Vec<&'static str> { + let parser = include_str!("backend/types/connect_params.rs"); + // `pub(crate) const PARAM_DATABASE: &str = "database";` + let mut keys: Vec<&str> = parser + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("pub(crate) const PARAM_")?; + let rest = rest.split_once(": &str = \"")?.1; + rest.strip_suffix("\";") + }) + .collect(); + keys.sort_unstable(); + keys + } + + /// The dialog's `$Fields` table offers exactly the keywords the parser + /// accepts, in both directions. + /// + /// Both sides are read from the files that ship rather than from a + /// transcribed list, so adding a connection-string key fails here until + /// the dialog offers it. + #[test] + fn dsn_keys_match_the_connection_string_parser() { + let dialog = include_str!("../packaging/windows/configure-dsn.ps1"); + let parser_keys = connection_string_keys(); + assert!( + !parser_keys.is_empty(), + "expected the PARAM_ constants to parse; got {parser_keys:?}" + ); + + // `Key='database'` in the field table. + let needle = "Key='"; + let mut dialog_keys: Vec<String> = dialog + .match_indices(needle) + .filter_map(|(i, _)| { + let rest = &dialog[i + needle.len()..]; + rest.split_once('\'').map(|(v, _)| v.to_string()) + }) + .collect(); + dialog_keys.sort(); + + let missing: Vec<&&str> = parser_keys + .iter() + .filter(|k| !dialog_keys.iter().any(|d| d == *k)) + .collect(); + assert!( + missing.is_empty(), + "connect_params.rs accepts {missing:?}, which configure-dsn.ps1's \ + field table does not offer; add an entry so Windows users can set it" + ); + + let unknown: Vec<&String> = dialog_keys + .iter() + .filter(|d| !parser_keys.iter().any(|k| k == *d)) + .collect(); + assert!( + unknown.is_empty(), + "configure-dsn.ps1 offers {unknown:?}, which connect_params.rs does \ + not accept; the driver would ignore it at connect" + ); + } + + /// `packaging/sbom-native.json` declares the SQLite that is actually + /// linked. + /// + /// SQLite is compiled into the driver from the amalgamation + /// `libsqlite3-sys` vendors, so cargo — and therefore `cargo auditable`, + /// syft and the SBOM — sees only the wrapper crate. The C library inside + /// it is the component an advisory against SQLite would name, and the only + /// place its version is written down is that fragment. A `libsqlite3-sys` + /// bump changes the bundled version with nothing else to notice. + /// + /// `rusqlite::version()` is the linked library answering for itself, which + /// is why it is the authority here rather than the crate version. + #[test] + fn the_declared_sqlite_version_is_the_one_linked() { + let fragment = include_str!("../packaging/sbom-native.json"); + let linked = rusqlite::version(); + + assert!( + fragment.contains(&format!("\"version\": \"{linked}\"")), + "sbom-native.json does not declare SQLite {linked}, which is what \ + rusqlite reports is linked in. Update the `common` entry's version \ + and purl; a release would otherwise ship an SBOM naming a SQLite \ + that is not in the binary." + ); + assert!( + fragment.contains(&format!("pkg:generic/sqlite@{linked}")), + "sbom-native.json's SQLite purl does not name {linked}; the version \ + field and the purl have to move together" + ); + } + + /// The dialog's `-DriverName` default is the name `install.bat` registers. + /// + /// Standalone, the script writes its data source through + /// `SQLConfigDataSourceW` under that name, so a mismatch produces a data + /// source pointing at a driver that is not installed. The two files are in + /// different languages and neither is checked against the other by any + /// toolchain. + #[test] + fn the_dialog_and_the_installer_agree_on_the_driver_name() { + let dialog = include_str!("../packaging/windows/configure-dsn.ps1"); + let installer = include_str!("../packaging/windows/install.bat"); + + let registered = installer + .split_once("{INSTALLDRIVER \"") + .and_then(|(_, rest)| rest.split_once('|')) + .map(|(name, _)| name) + .expect("install.bat registers a driver through odbcconf INSTALLDRIVER"); + + assert!( + dialog.contains(&format!("$DriverName = '{registered}'")), + "install.bat registers {registered:?}, which is not configure-dsn.ps1's \ + default $DriverName; a data source written by the dialog would name a \ + driver that is not installed" + ); + } +} From 666cdaccd97c8dc7c1e8c086b6d8c9840b83d8f9 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 16:13:19 +0200 Subject: [PATCH 32/50] docs: prepare the documentation for a first release, and fix five stale claims The docs described a driver that no longer matches the code in five places, and read as development history rather than as reference material. Corrected: - AGENTS.md called core a path dependency with a matching `TODO`, and said CI could not pass because of it. Core is a git dependency, there is no `TODO`, and CONTRIBUTING.md already documented the real `[patch]` mechanism. - WINDOWS.md still described the driver as headless with no setup dialog, which the previous commit added. It also told the reader to use the DSN `test_sqlite` and then created `MySQLite` against another database. - A 54-line doc block in `info.rs` had no blank line after it, so it attached to `SQLITE_SUBQUERIES` and left `SQLITE_ALTER_TABLE` undocumented. - The README's `isql` example named `test/test.db`, which does not exist, and a release build, where `setup.sh` produces a debug one. - Four files claimed all three generated files embed absolute paths. Only `odbc.ini` and `odbcinst.ini` do; `test.db` is a binary. Two flags `windows_test.py` accepts, `--target` and `--vm-network`, were missing from the docs and from the `--help` text `lib.sh` derives from the run-tests.sh header. Restructured: - CHANGELOG.md becomes a first-release capability statement, as the Trino driver's does, replacing 414 lines of development history. - README.md follows the Trino driver's order and gains Compatibility, Troubleshooting and Getting help. Testing and Releasing move to CONTRIBUTING.md, and the shared-core framing moves with them. - AGENTS.md leads with the architecture rather than reaching it 61% in, and the ODBC design rationale gets its own section instead of sitting under Conventions. The module table gains `setup.rs`, `build.rs` and `benches/`, and a section covers the setup dialog and the test that pins it. - CLAUDE.md links into AGENTS.md for the six rules it restated verbatim. - packaging/README.md puts support and the SBOM ahead of the build instructions, since it ships inside the archive. Prose: em dashes drop from 31 to 1, the survivor being the changelog heading format in release.toml, which the Trino driver shares. Comments that narrated what core used to default to are now present-tense statements of the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .gitignore | 2 +- AGENTS.md | 257 ++++++------ CHANGELOG.md | 465 +++------------------- CLAUDE.md | 78 ++-- CONTRIBUTING.md | 13 +- README.md | 310 +++++++-------- benches/fetch_sqlite.rs | 13 +- integration-tests/README.md | 21 +- integration-tests/generated/.gitignore | 2 +- integration-tests/scripts/lib.sh | 6 +- integration-tests/scripts/run-tests.sh | 5 +- integration-tests/windows/WINDOWS.md | 207 ++++++---- integration-tests/windows/windows_test.py | 4 +- packaging/README.md | 42 +- packaging/sbom.sh | 8 +- release.toml | 4 +- release/release.sh | 6 +- src/backend.rs | 30 +- src/backend/info.rs | 156 ++++---- src/backend/metadata.rs | 15 +- src/backend/setup.rs | 2 +- src/escape_dialect.rs | 52 +-- src/lib.rs | 4 +- src/type_conversion.rs | 4 +- 24 files changed, 701 insertions(+), 1005 deletions(-) diff --git a/.gitignore b/.gitignore index 5b9df4f..caff3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ tags # Release packaging output packaging/dist/ -# integration-tests/generated/ has its own .gitignore; everything setup.sh +# integration-tests/generated/ has its own .gitignore; the ODBC config setup.sh # writes there embeds absolute paths. # Python bytecode from the test scripts diff --git a/AGENTS.md b/AGENTS.md index f6e74b2..0c9c79c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,8 @@ the C ABI entry points) lives in | Topic | When to Read | |-------|-------------| -| [Relationship to core](#relationship-to-stackable-odbc-core) | Deciding where a change belongs | +| [Architecture](#architecture-of-this-crate) | Finding the module a change belongs in | +| [Relationship to core](#relationship-to-stackable-odbc-core) | Deciding whether a change belongs here at all | | [Conventions](#conventions) | Any code change | | [Backend error mapping](#backend-error-mapping) | Touching an error path | | [Declaring capabilities](#declaring-capabilities) | Adding or changing any `SQLGetInfo` value | @@ -22,10 +23,9 @@ the C ABI entry points) lives in | [Cancellation](#cancellation) | Touching `SQLCancel` or `SQL_ATTR_QUERY_TIMEOUT` | | [`row_count` has three answers](#row_count-has-three-answers-not-two) | Touching `SQLRowCount` or the execute path | | [Catalog functions](#catalog-functions) | Touching anything in `metadata.rs` | -| [Architecture](#architecture-of-this-crate) | Understanding the module layout | | [Connection string keys](#connection-string-keys) | Adding or changing a parameter | | [Testing](#testing) | Writing or running tests | -| [Packaging](#packaging) | Cutting a release | +| [Packaging and release](#packaging-and-release) | Cutting a release | ```bash cargo build # needs unixodbc-dev @@ -37,18 +37,44 @@ pre-commit run --all-files # the gate; run before every commit ./integration-tests/run-tests.sh # run the integration suite ``` -## Relationship to stackable-odbc-core +## Architecture of this crate + +| Path | Responsibility | +|------|----------------| +| `src/lib.rs` | The `forward_ffi!` invocation, the crate docs, and the packaging consistency tests | +| `src/backend.rs` | `SqliteBackend`, `SqliteConnection`, `SqliteStatement`, `SqliteError`, `map_sqlite_error` | +| `src/backend/execute.rs` | `exec_direct`, `prepare`, `execute`, and the `StatementBackend` impl | +| `src/backend/info.rs` | `SQLGetInfo` answers and the capability bitmaps, plus the snapshot test | +| `src/backend/metadata.rs` | The catalog row producers: tables, columns, primary keys, statistics, special columns | +| `src/backend/setup.rs` | `Backend::configure_dsn`, the Windows DSN setup dialog | +| `src/backend/params.rs` | Deliberately empty. Parameter binding is inline in `execute.rs`; the entry points are core's | +| `src/backend/types/connect_params.rs` | `SqliteConnectParams` | +| `src/escape_dialect.rs` | ODBC escape-sequence translation for SQLite's dialect | +| `src/type_conversion.rs` | SQLite storage classes and declared types → ODBC SQL types | +| `src/ffi_integration_tests.rs` | Tests that drive the real C ABI entry points | +| `build.rs` | Embeds the Windows version resource with `windres` | +| `benches/fetch_sqlite.rs` | Criterion fetch-throughput benchmark through the full FFI path | -`stackable-odbc-core` is a path dependency on a sibling checkout until it is -published: +### Result sets are materialised eagerly -```toml -stackable-odbc-core = { path = "../stackable-odbc-core" } -``` +`SqliteStatement` holds `rows: Vec<Vec<ColumnValue>>` and `cursor: i64`, an +index into an in-memory snapshot rather than a live SQLite cursor. +`exec_direct` collects every row before returning, and the +`rusqlite::Statement` is finalized at that point. + +This is load-bearing well beyond memory use. It is why the cursor-behaviour +hooks report `Preserve`, why `SQLEndTran` cannot disturb a cursor, and why +concurrency is a non-issue. Changing it is not a local optimisation. See +[Transactions](#transactions). -There is a matching `TODO` in `Cargo.toml`. Until it is resolved, CI cannot -pass, because a path dependency does not resolve on a runner. This crate is not published to -crates.io; releases are GitHub Release archives built by +## Relationship to stackable-odbc-core + +Core is a git dependency, pinned in `Cargo.toml`, and cargo fetches it for you. +To build against a local checkout instead, add a `[patch]` to your own +`.cargo/config.toml`; see +[CONTRIBUTING.md](CONTRIBUTING.md#working-on-core-at-the-same-time) for the +mechanics and the `Cargo.lock` caveat. This crate is not published to +crates.io. Releases are GitHub Release archives built by `.github/workflows/release.yaml`. | Concern | Owner | @@ -65,6 +91,7 @@ crates.io; releases are GitHub Release archives built by | Catalog column layout, sort order, the `SQL_ALL_*` enumerations | core | | Connection-string parsing | this crate | | ODBC escape-sequence translation | this crate | +| All of `ConfigDSN`, apart from the dialog itself | core, see [The Windows setup dialog](#the-windows-setup-dialog) | `src/lib.rs` is the whole export surface: @@ -130,10 +157,9 @@ re-exports. Do not add `odbc-sys` to `Cargo.toml`. Core also re-exports the crate wholesale as `stackable_odbc_core::odbc_sys`, so a type with no `types` re-export of its own is still reachable without a direct dependency. Reach for that rather than hand-rolling a `#[repr(C)]` -mirror: `src/ffi_integration_tests.rs` used to carry a local `RawTimestamp` -duplicating `SQL_TIMESTAMP_STRUCT`, and a mirror that drifts from the real -struct is two different types to the compiler and one silent ABI mismatch to -the application. +mirror of a struct like `SQL_TIMESTAMP_STRUCT`: a mirror that drifts from the +real struct is two different types to the compiler and one silent ABI mismatch +to the application. ### Type cast safety @@ -178,23 +204,27 @@ For this driver `connect` is where real I/O happens: `rusqlite::Connection::open` touches the filesystem, so a missing or unreadable database file is `08001`. Failures after that point are `08S01`. +## ODBC behaviour and design rationale + +Everything in this section describes behaviour an application can observe, and +each decision is anchored to a spec page or to measured SQLite behaviour. Read +the relevant part before changing what the driver reports. + ### Declaring capabilities -`Backend` has around thirty **required** methods that state what SQLite can +Most of `Backend` is **required** methods that state what SQLite can do: `alter_table_support`, `outer_join_capabilities`, `subqueries`, `sql_conformance`, `supports_catalogs`, `identifier_case`, `quoted_identifier_case`, `txn_capable`, `txn_isolation_options`, `integrity`, `multiple_active_txn`, `special_characters`, `accessible_procedures`, `dbms_name`, `dbms_version`, `table_types` and the rest. They are required, with no default, deliberately: a defaulted capability is a claim no backend -ever made, and every one of them was a bug here before core made it a compile -error. `table_types` is required for the same reason and one of its own: an -empty table-type list is an *answer* ("this data source has no table types"), -not "unknown", and unlike catalogs and schemas there is no `supports_*` method -for core to derive it from. `special_characters` is required on that same -principle: `""` asserts that nothing beyond the alphanumerics and underscore -is legal unquoted, which is a claim, not an absence, and inheriting it as a -default is how this driver came to under-report `$`. +ever made. `table_types` is required for the same reason and one of its own, +since an empty table-type list is an *answer* ("this data source has no table +types") rather than "unknown", and unlike catalogs and schemas there is no +`supports_*` method for core to derive it from. `special_characters` follows +the same principle, because `""` asserts that nothing beyond the alphanumerics +and underscore is legal unquoted, which is a claim rather than an absence. They all take `&Self::Connection`, because `SQLGetInfo` is a per-connection call and a data source's capabilities can differ by server. Every one this @@ -202,13 +232,13 @@ driver declares is a property of the SQLite `rusqlite` links, not of the file opened, so each ignores the argument, but the answer must still be read through a connection, and the tests do that via `info::tests::test_connection` rather than calling the hook as a free function. `cursor_commit_behavior`, -`cursor_rollback_behavior`, `catalog_result_column_widths`, `driver_name` and -`driver_version` are the exceptions and take none: `SQLGetInfo` must answer the -first three before a connection exists, and the Windows Driver Manager asks for -driver identity before `SQLDriverConnectW`. Note the split within the identity +`cursor_rollback_behavior`, `driver_name` and `driver_version` are the +exceptions and take none: `SQLGetInfo` must answer the cursor-behaviour pair +before a connection exists, and the Windows Driver Manager asks for driver +identity before `SQLDriverConnectW`. Note the split within the identity group: `driver_name`/`driver_version` describe the driver and take no connection, while `dbms_name`/`dbms_version` describe what was connected to and -take one. +take one. (`catalog_result_column_widths` is core's, defaulted here.) The same split runs through `get_info`. `sqlite_get_info` takes `Option<&SqliteConnection>` (`None` on the pre-connect path) and hands it to @@ -218,23 +248,23 @@ hook must therefore be guarded on the connection being present, which is why `SQL_MAX_CATALOG_NAME_LEN` and `SQL_MAX_SCHEMA_NAME_LEN` only report `0` once one is open. -Four rules, all learned the hard way: +Four rules: **Declare it once.** A capability with a hook is answered *only* through the hook, never also in `get_info_raw`. Core derives the info type from the hook, so a second answer is a value that can disagree with itself, and the one an -application sees depends on which core consults first. `SQL_IDENTIFIER_CASE` -was stated in both places; so was `SQL_GETDATA_EXTENSIONS`, which is not even a -fact about SQLite: it describes core's own fetch path, and belongs to core for -the same reason. The snapshot test (`get_info_snapshot`) pins the value an -application sees regardless of who answers it, which is what makes moving an -answer safe. +application sees depends on which core consults first. `SQL_GETDATA_EXTENSIONS` +belongs to core for a related reason: it describes core's own fetch path rather +than any fact about SQLite. The snapshot test (`get_info_snapshot`) pins the +value an application sees regardless of who answers it, which is what makes +moving an answer safe. **Probe the bundled library, never the documentation or the system CLI.** -`rusqlite` links its own SQLite (3.53.2 via the `bundled` feature); the -`sqlite3` binary on a developer's machine is a different version. Writing the -`ALTER TABLE` bitmap from the system CLI's behaviour got `ADD CONSTRAINT` and -`DROP CONSTRAINT` wrong, because 3.51.3 rejects both and 3.53.2 accepts them. +`rusqlite` links its own SQLite (3.53.2 via the `bundled` feature), and the +`sqlite3` binary on a developer's machine is a different version. The +difference is not academic: 3.51.3 rejects `ADD CONSTRAINT` and +`DROP CONSTRAINT` where 3.53.2 accepts them, so an `ALTER TABLE` bitmap written +from the system CLI's behaviour understates what the driver actually links. `alter_table_capabilities_are_each_live_probed`, `outer_join_capabilities_are_each_live_probed` and `subqueries_are_each_live_probed` all execute the syntax they describe. @@ -242,20 +272,21 @@ answer safe. **Probe the bits you do not claim, too.** A test that only checks what a bitmap claims can overclaim forever, and a bitmap that only grows when someone notices can understate forever. The negative half of the `ALTER TABLE` probe is what -caught the two bits above. `SQL_KEYWORDS` goes further and reads the list out -of the library through `sqlite3_keyword_count` / `sqlite3_keyword_name`, so it -needs no maintenance at all. +catches a version difference like the one above. `SQL_KEYWORDS` goes further +and reads the list out of the library through `sqlite3_keyword_count` / +`sqlite3_keyword_name`, so it needs no maintenance at all. -**Values must agree with each other.** Most defects found in this crate were -one capability stated twice, in opposite directions: +**Values must agree with each other.** The commonest defect in a capability +table is one fact stated twice, in opposite directions. These pairs each +describe the same thing and must be changed together: -| Said one thing | Said the opposite | +| Info type | Must agree with | |---|---| -| `SQL_CATALOG_NAME = "N"` | `SQL_CATALOG_TERM = "catalog"` | -| `SQL_OUTER_JOINS = "Y"` | `SQL_OUTER_JOIN_CAPABILITIES = 0` | -| `SQL_SQL_CONFORMANCE = SQL_SC_SQL92_ENTRY` | `SQL_GROUP_BY = SQL_GB_NO_RELATION` | -| `SQL_SQL92_PREDICATES` without `SQL_SP_QUANTIFIED_COMPARISON` | `SQL_SUBQUERIES` with `SQL_SQ_QUANTIFIED` | -| `SQL_TXN_ISOLATION_OPTION` with four levels | nothing applying the level an application sets | +| `SQL_CATALOG_NAME` | `SQL_CATALOG_TERM`, `SQL_CATALOG_LOCATION`, `SQL_CATALOG_USAGE`, `SQL_CATALOG_NAME_SEPARATOR` | +| `SQL_OUTER_JOINS` | `SQL_OUTER_JOIN_CAPABILITIES` | +| `SQL_SQL_CONFORMANCE` | `SQL_GROUP_BY`, `SQL_CONCAT_NULL_BEHAVIOR`, `SQL_NON_NULLABLE_COLUMNS` | +| `SQL_SQL92_PREDICATES` (`SQL_SP_QUANTIFIED_COMPARISON`) | `SQL_SUBQUERIES` (`SQL_SQ_QUANTIFIED`) | +| `SQL_TXN_ISOLATION_OPTION` | whatever actually applies the level an application sets | When adding or changing a capability, look for the other info type that talks about the same thing, and assert the relationship. @@ -288,8 +319,8 @@ be `SQL_CB_CLOSE`, and a COMMIT with pending writes fails with `SQLITE_BUSY`. If result sets ever become lazily streamed, both hooks must be revisited, and `SQL_CB_CLOSE` would additionally require a real -`StatementBackend::close_cursor`, which is fallible now (`Result<(), -Self::Error>`), because under `SQL_CB_CLOSE` it is the only thing that closes +`StatementBackend::close_cursor`. That method is fallible (`Result<(), +Self::Error>`) because under `SQL_CB_CLOSE` it is the only thing that closes the cursor during `SQLEndTran`, and a failure has to reach the statement's diagnostic queue rather than be swallowed. Here it only resets an index into an already-materialised `Vec`, so it cannot fail. @@ -346,48 +377,21 @@ the return code. Note the gate it holds: `SQLCancel`'s idle branch clears the statement's diagnostic queue, so a cancel landing after `SQLExecDirectW` returns would wipe the `HY008` the test is reading. -`SQL_ATTR_QUERY_TIMEOUT` is still substituted with `0` and reported as `01S02`, +`SQL_ATTR_QUERY_TIMEOUT` is substituted with `0` and reported as `01S02`, because this driver does not override `Backend::set_query_timeout` and the default answers `NotImplemented`. -**That is now a gap rather than an impossibility.** The original reason (a -synchronous execute path with no deadline to arm) no longer holds: core owns -the timer (`query_timer.rs`), and `Ok(QueryTimeout::CoreCancels)` asks it to arm -one and call `Backend::cancel` when the deadline passes. `cancel` is real here, -which is exactly the precondition `CoreCancels` documents. Closing the gap means +**This is a gap rather than an impossibility.** Core owns the timer +(`query_timer.rs`), and `Ok(QueryTimeout::CoreCancels)` asks it to arm one and +call `Backend::cancel` when the deadline passes. `cancel` is real here, which is +exactly the precondition `CoreCancels` documents. Closing the gap means overriding `set_query_timeout` to return `CoreCancels`, and overriding -`is_cancelled` alongside it, since that is what turns the interrupted statement's -own symptom into the `HYT00` the application is waiting for rather than the -`HY008` a user-initiated `SQLCancel` produces. `SQL_ATTR_QUERY_TIMEOUT` is a -*statement* attribute while the hook receives only the connection, so read +`is_cancelled` alongside it, since that is what turns the interrupted +statement's own symptom into the `HYT00` the application is waiting for rather +than the `HY008` a user-initiated `SQLCancel` produces. `SQL_ATTR_QUERY_TIMEOUT` +is a *statement* attribute while the hook receives only the connection, so read core's scope caveat on `set_query_timeout` before doing it. -## Architecture of this crate - -| Path | Responsibility | -|------|----------------| -| `src/lib.rs` | The `forward_ffi!` invocation and the crate docs | -| `src/backend.rs` | `SqliteBackend`, `SqliteConnection`, `SqliteStatement`, `SqliteError`, `map_sqlite_error` | -| `src/backend/execute.rs` | `exec_direct`, `prepare`, `execute`, and the `StatementBackend` impl | -| `src/backend/info.rs` | `SQLGetInfo` answers and the capability bitmaps, plus the snapshot test | -| `src/backend/metadata.rs` | The catalog row producers: tables, columns, primary keys, statistics, special columns | -| `src/backend/params.rs` | Deliberately empty. Parameter binding is inline in `execute.rs`; the entry points are core's | -| `src/backend/types/connect_params.rs` | `SqliteConnectParams` | -| `src/escape_dialect.rs` | ODBC escape-sequence translation for SQLite's dialect | -| `src/type_conversion.rs` | SQLite storage classes and declared types → ODBC SQL types | -| `src/ffi_integration_tests.rs` | Tests that drive the real C ABI entry points | - -### Result sets are materialised eagerly - -`SqliteStatement` holds `rows: Vec<Vec<ColumnValue>>` and `cursor: i64`, an -index into an in-memory snapshot, not a live SQLite cursor. `exec_direct` -collects every row before returning and the `rusqlite::Statement` is finalized -at that point. - -This is load-bearing well beyond memory use. It is why the cursor-behaviour -hooks report `Preserve`, why `SQLEndTran` cannot disturb a cursor, and why -concurrency is a non-issue. Changing it is not a local optimisation. - ### `row_count` has three answers, not two `StatementBackend::row_count` returns `Option<i64>`, and core reads all three @@ -403,7 +407,7 @@ The distinction between the last two is not cosmetic. Core turns a statement with **zero columns** reporting **`Some(0)`** into `SQL_NO_DATA`, which is `SQLExecDirect`'s documented behaviour for "a searched update, insert, or delete statement that doesn't affect any rows". Answering `Some(0)` for DDL -therefore made every `CREATE TABLE` return `SQL_NO_DATA`. +would therefore make every `CREATE TABLE` return `SQL_NO_DATA`. SQLite offers no predicate for "is this DML" (`sqlite3_stmt_readonly` is false for DDL too), so `execute::is_searched_dml` decides it from the statement's @@ -443,13 +447,13 @@ Both sides are core's types and both are sealed, which is what a change in crate-private fields, an accessor and a `with_*` setter per field, and a `new()` for the arguments that have no honest default (`StatisticsQuery`'s `unique_only`, `SpecialColumnsQuery`'s `identifier_type`/`scope`/`nullable`). -- **Read the filters off the query, do not destructure it.** The run of - same-typed `Option<&str>` arguments these hooks used to take is exactly what - the query types exist to remove: `SQLForeignKeys` took six in a row, where - swapping a primary-key argument for its foreign-key counterpart compiled - without complaint. Unpacking a query back into positional arguments at the - trait boundary reintroduces that hazard one layer down, so the query travels - all the way into `metadata.rs`. +- **Read the filters off the query, do not destructure it.** A run of + same-typed `Option<&str>` arguments is exactly what the query types exist to + remove: `SQLForeignKeys` takes six filters, where swapping a primary-key + argument for its foreign-key counterpart would compile without complaint. + Unpacking a query back into positional arguments at the trait boundary + reintroduces that hazard one layer down, so the query travels all the way + into `metadata.rs`. - **`TablesQuery::table_types()` is already parsed.** Core splits `TableType` on commas and strips the optional single quotes (it is a value list, not a pattern, and `SQL_ATTR_METADATA_ID` never applies to it), so a backend gets a @@ -462,7 +466,7 @@ Three further consequences for anything changed in `metadata.rs`: - **Do not sort, and do not add an `ORDER BY` for ODBC's sake.** Core sorts, stably, on the spec's keys. A second ordering in the backend is one more - place for it to be wrong, and it silently overrides nothing: core re-sorts + place for it to be wrong, and it overrides nothing, because core re-sorts regardless. The one thing to keep in mind is that the sort takes NULL placement from `Backend::null_collation`, which is why `SQLStatistics`' table-stat row (NULL `NON_UNIQUE`) still comes first: this driver reports @@ -475,16 +479,33 @@ Three further consequences for anything changed in `metadata.rs`: `catalogs` and `schemas` are left defaulted here because the first two hooks say SQLite has neither, so core never asks. - **A non-`Option` field is a column the spec marks "not NULL".** The types - enforce it, which is how `SQLForeignKeys`' `PKCOLUMN_NAME` stopped being - reported as NULL for a `REFERENCES parent` with no column list. SQLite - defines that as the parent's primary key, so `parent_pk_column` resolves the - name rather than dropping it. + enforce it, which is what keeps `SQLForeignKeys`' `PKCOLUMN_NAME` populated + for a `REFERENCES parent` with no column list. SQLite defines that as the + parent's primary key, so `parent_pk_column` resolves the name rather than + dropping it. Because ordering is core's, an ordering assertion belongs in `ffi_integration_tests.rs`, where core's sort has actually run. The unit tests in `metadata.rs` assert only which rows exist and what each field holds. See `sql_statistics_w_orders_table_stat_row_first_then_unique_before_non_unique`. +### The Windows setup dialog + +`Backend::configure_dsn` (`src/backend/setup.rs`) supplies one thing: the +dialog. Core owns the rest of `ConfigDSN`, meaning validation of *fRequest*, +rejecting `DRIVER=`, merging the data source's stored keywords in, calling +`SQLValidDSN` and writing through `SQLWriteDSNToIni`. + +The dialog itself is `packaging/windows/configure-dsn.ps1`, which also runs +standalone for a scripted install (`-NoGui -Set @{...}`). `install.bat` +installs it beside the DLL and refuses to register the driver without it. + +Nothing compiles the PowerShell, so `dsn_keys_match_the_connection_string_parser` +in `src/lib.rs` reads both the parser's `PARAM_*` constants and the dialog's +`$Fields` table out of the shipping files and asserts they offer the same keys +in both directions. A keyword added to one side and not the other fails the +build rather than surfacing as a box a user can fill in that the driver ignores. + ## Connection string keys Keys are matched case-insensitively and stored lowercase by core's @@ -494,10 +515,14 @@ Keys are matched case-insensitively and stored lowercase by core's |-----|----------|-------------| | `Database` | Yes | Path to the database file, or `:memory:` | -Adding a key means adding a `PARAM_*` constant in -`src/backend/types/connect_params.rs`, reading it in the `TryFrom` impl, and -listing it in `Backend::browse_connect_attrs` if `SQLBrowseConnect` should -prompt for it. +Adding a key means four edits, and the build fails until the last two agree: + +1. A `PARAM_*` constant in `src/backend/types/connect_params.rs`, read in the + `TryFrom` impl. +2. The table in [`README.md`](README.md), and the one in + [`packaging/README.md`](packaging/README.md) that ships in the archive. +3. The `$Fields` table in `packaging/windows/configure-dsn.ps1`. +4. `Backend::browse_connect_attrs`, if `SQLBrowseConnect` should prompt for it. ## Testing @@ -520,7 +545,7 @@ otherwise ship inside the driver binary. **Set up test data through the FFI, not by reaching into the handle.** Core's `handles` module is `pub(crate)`, so `ConnectionHandle` and the -`rusqlite::Connection` inside it are no longer reachable from here, so use the +`rusqlite::Connection` inside it are not reachable from here. Use the `setup_sql`, `query_scalar_i64` and `query_row_two_strings` helpers, which go through `SQLExecDirect`/`SQLFetch`/`SQLGetData`. Each allocates its own statement handle rather than borrowing the caller's, because the statement a @@ -542,8 +567,8 @@ with no data source open. Both are wrappers; the logic is in `integration-tests/scripts/`, with the paths and helpers they share in `scripts/lib.sh`. Everything `setup.sh` writes lands -in `integration-tests/generated/`, which is gitignored wholesale because all of -it embeds absolute paths. See +in `integration-tests/generated/`, which is gitignored wholesale because the +ODBC config there names absolute paths. See [integration-tests/README.md](integration-tests/README.md) for the layout and why the pyodbc suite is run twice. @@ -565,20 +590,26 @@ the `SqliteBackend` → `ColumnValue` → `write_column_value` pipeline. ### What runs in core, not here -Do not reintroduce these; they moved with the framework: - - **Miri.** The driver crates link C libraries (bundled SQLite) that Miri cannot execute. Core is pure Rust and holds the raw-pointer marshalling. - **Fuzzing.** The `utf16` and `column_value` fuzz targets fuzz core's code. - **Generic FFI entry-point tests.** Handle tags, panic safety and diagnostics are core's. -## Packaging +## Packaging and release `packaging/build-archives.sh` assembles the Linux and Windows release archives from binaries already built by `cargo build --release`; see [packaging/README.md](packaging/README.md). +Anything destined for an archive is built with `cargo auditable`, which embeds +the dependency list `packaging/sbom.sh` generates the CycloneDX and SPDX +documents from. `sbom.sh` refuses an artifact without it, and +`packaging/test-sbom.sh` is that pipeline's own test suite. The bundled SQLite +version is declared in `packaging/sbom-native.json` and pinned against the +linked library by `the_declared_sqlite_version_is_the_one_linked` in +`src/lib.rs`. + ### Cutting a release `release.toml` configures `cargo-release`. It bumps the version, rewrites diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f8849..27f2e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,408 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- **A setup dialog on Windows.** The ODBC Data Source Administrator's **Add…** - and **Configure…** buttons now display a dialog instead of silently writing a - data source with no `Database` key. It asks for the data source name and the - database file, offers a file browser, and has a **Test connection** button - that opens the file and reports the SQLite version and how many tables it - found — which is the check worth having, because SQLite creates a missing - file rather than refusing, so a typo in the path connects perfectly well and - finds nothing. - - This is `stackable-odbc-core`'s new `Backend::configure_dsn` hook: core owns - all of `ConfigDSN` — validating the request, merging the data source's stored - keywords in, and writing through `SQLWriteDSNToIni` — and this driver - supplies only the dialog. The dialog itself is - `packaging/windows/configure-dsn.ps1`, which also runs standalone for a - scripted install (`-NoGui -Set @{...}`). `install.bat` installs it beside the - DLL and refuses to register the driver without it. - - Cancelling the dialog leaves the data source untouched and posts no error. - A **Remove** never prompts: the Administrator has already confirmed it, and - removing a data source does not touch the database file it points at. - -- **The Windows DLL carries a version resource.** The ODBC Data Source - Administrator listed the driver as `Not marked` under Version and Company, - because no Rust `cdylib` emits one. `build.rs` now generates it with - `windres`, taking every string from `Cargo.toml` through cargo's own - environment, so it cannot disagree with the package. - -- **Every release archive carries an SBOM.** One CycloneDX and one SPDX - document per artifact, generated from the dependency list `cargo auditable` - embeds in the binary rather than from `Cargo.toml`, so it describes what was - linked: dev-dependencies are excluded by construction, and a git dependency's - purl names the resolved commit rather than a branch that moves. - - Two components cargo cannot see are declared by hand in - `packaging/sbom-native.json` and verified against the real binaries by - `packaging/sbom.sh --check-native`, which CI runs on every pull request: the - bundled SQLite itself, which cargo sees only as the `libsqlite3-sys` wrapper, - and what each artifact links at load time. The release page also carries - `sha256sums.txt` and build-provenance attestations. - -- `SQLCancel` actually cancels. A statement running on one thread can be - stopped from another, which is the case the spec singles out: the driver now - holds `sqlite3_interrupt`'s handle for the connection and calls it, so the - in-flight query fails with SQLSTATE `HY008` ("operation canceled") instead of - running to completion. It previously reported "not implemented", which - `SQLCancel` treats as success — an application that asked to stop a runaway - query got `SQL_SUCCESS` and then waited for it anyway. Cancelling an idle - statement is still a no-op and still succeeds, per spec, and a cancelled - statement can be re-executed. - - `SQL_ATTR_QUERY_TIMEOUT` is unaffected and still substituted with `0`: - cancellation is a signal from another thread, whereas a timeout would need a - deadline this driver's synchronous execution path has nothing to arm. - -- `SQLTables` answers the `SQL_ALL_CATALOGS`, `SQL_ALL_SCHEMAS` and - `SQL_ALL_TABLE_TYPES` enumerations, which is how a BI tool's navigator - browses a data source. `SQL_ALL_TABLE_TYPES` reports `TABLE` and `VIEW`, the - two values `SQLTables` can put in `TABLE_TYPE`; the other two are empty - result sets, SQLite having neither catalogs nor schemas. The driver used to - answer the table-type case itself and now declares the list through the new - `Backend::table_types` hook, with `stackable-odbc-core` detecting all three - enumerations and serving them — including the distinction that makes them - work, since all three sentinels are the same `"%"` and differ only in which - argument carries it while the others are empty strings. - -- `SQL_ATTR_ROWS_FETCHED_PTR`, `SQL_ATTR_ROW_STATUS_PTR` and - `SQL_ATTR_ROW_BIND_OFFSET_PTR` are honoured instead of accepted and ignored. - With `SQL_ATTR_ROW_ARRAY_SIZE` pinned at 1 the rowset holds exactly one row, - so the fetched count is 1 per row and 0 at `SQL_NO_DATA`, the status is - `SQL_ROW_SUCCESS` (or `SQL_ROW_SUCCESS_WITH_INFO` when the row raised - `01004`), and the bind offset is added to every bound column and indicator - address on each fetch. This follows a `stackable-odbc-core` change. - -- `SQLGetData` retrieves a long character or binary value in parts, returning - `SQL_SUCCESS_WITH_INFO` with `01004` and resuming from the read position on - the next call, rather than restarting from the beginning each time. This - follows a `stackable-odbc-core` change. - -- Diagnostics now carry SQLite's own error code and the failure that caused - them. `map_sqlite_error` keeps the `rusqlite::Error` it classified rather - than flattening it into a message, so `SQLGetDiagRec` reports SQLite's - *extended* result code verbatim through `NativeErrorPtr` and the diagnostic - message includes the whole causal chain. Every error this driver produced - previously reached the application as native code `0`. The extended code is - the one worth having: it separates `SQLITE_CONSTRAINT_NOTNULL` (1299) from - `SQLITE_CONSTRAINT_FOREIGNKEY` (787), which the primary code and SQLSTATE - both report identically as a constraint violation. - -- `SQLColAttribute` answers `SQL_DESC_BASE_TABLE_NAME` for a column that comes - from a stored table, read from `sqlite3_table_column_metadata`. The catalog - and schema stay empty, because this driver reports that it has neither. - -- Initial extraction of `stackable-odbc-sqlite` into its own repository, from - the `stackable-odbc-rs` workspace it was developed in. Provides the ODBC - driver for SQLite: the `Backend` and `StatementBackend` implementations, - connection-string parsing, SQLite-to-ODBC type conversion, ODBC - escape-sequence translation, and the catalog and metadata functions, with the - C ABI entry points generated by `stackable-odbc-core`'s `forward_ffi!` macro. - -### Changed - -- `stackable-odbc-core` is taken from git rather than from a sibling checkout, - so a clean clone and CI can build this driver without one. To work on core at - the same time, put a `[patch]` in your own `.cargo/config.toml`; see - [`CONTRIBUTING.md`](CONTRIBUTING.md). - -- `SQL_QUOTED_IDENTIFIER_CASE` reports `SQL_IC_MIXED` instead of - `SQL_IC_SENSITIVE`. In SQLite, double quotes are a *delimiter* — they let a - keyword or a name with punctuation be used as an identifier — and do not - switch on case-sensitive matching the way they do in a SQL-92 conformant - DBMS: a table created as `"MixedCase"` is found by `"mixedcase"`, and the - catalog stores the name with the case it was written in. The old value told - an application that `"T"` and `"t"` were different tables. Both halves of the - new claim — case-insensitive matching and mixed-case storage — are probed - against the bundled library rather than read off the documentation. - -- `SQL_SPECIAL_CHARACTERS` reports `$` instead of the empty string. SQLite's - tokenizer treats `$` as an identifier character, so `a$b` parses undelimited - and round-trips through `sqlite_master` unchanged. An application reads this - info type to decide when it must quote, and the empty string had it quoting a - name that needs no quoting. The empty string was `stackable-odbc-core`'s - default rather than a claim this driver ever made; it is now a per-connection - `Backend` hook, and every candidate character is executed against the bundled - library, the rejected ones included. - -- `SQL_CURSOR_SENSITIVITY` reports `SQL_UNSPECIFIED` instead of - `SQL_INSENSITIVE`, and `SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES2` reports - `SQL_CA2_READ_ONLY_CONCURRENCY` instead of `0`. Both describe - `stackable-odbc-core`'s own fetch path rather than SQLite, and both now come - from core: insensitivity would be a promise that no other cursor's changes - become visible, which core does not make about rows it has not read yet, - while `0` for the second denied the one concurrency - `SQLSetStmtAttr(SQL_ATTR_CONCURRENCY)` actually accepts. This follows a - `stackable-odbc-core` change. - -- `SQLDescribeCol` and `SQLColAttribute` report each result column's real - nullability instead of claiming every column is nullable. A column declared - `NOT NULL` is now `SQL_NO_NULLS`, a plain table column `SQL_NULLABLE`, and a - computed column — an expression, a literal, an aggregate — - `SQL_NULLABLE_UNKNOWN`. The third is the point: `sqlite3_table_column_metadata` - reports nothing for a computed column, so the driver genuinely cannot - determine the answer, and the spec has a value for exactly that rather than - requiring a guess. Guessing is not harmless in either direction: - `SQL_NO_NULLS` tells an application it may skip a NULL check it needs, and - `SQL_NULLABLE` makes it write one it does not. Requires `rusqlite`'s - `column_metadata` feature. - -- `SQLGetFunctions` reports every function the driver actually exports, derived - from `stackable-odbc-core`'s `CORE_EXPORTED_FUNCTIONS`, rather than a - hand-written list. The list had drifted to 53 of the 69 exported entry - points, so sixteen the driver does export were reported as unsupported — - including `SQLAllocConnect`, `SQLTransact`, `SQLExtendedFetch` and the - descriptor-field functions. It over-claimed nothing, which is the direction - that matters: `SQLGetFunctions` is what the Windows Driver Manager builds its - dispatch table from, so naming a function core does not export would hand it - a null pointer. A test keeps the historical list checked against core's. - -- `SQLSetStmtAttr(SQL_ATTR_QUERY_TIMEOUT)` and - `SQLSetStmtAttr(SQL_ATTR_MAX_ROWS)` now substitute `0` and return - `SQL_SUCCESS_WITH_INFO` with SQLSTATE `01S02` for any other value, where both - were previously stored and echoed back by `SQLGetStmtAttr`. Both are on the - spec's `01S02` substitution list. Nothing in this driver counts rows or - enforces a deadline — `Backend` is synchronous and `SQLCancel` is not - implemented — so an application that set a 30-second timeout and got - `SQL_SUCCESS` would wait indefinitely on a runaway query. Setting either to - `0` still succeeds plainly, that being the value the driver honours. This - follows a `stackable-odbc-core` change. - -- An infinite `REAL` read as `SQL_C_CHAR` or `SQL_C_WCHAR` now renders as - `Infinity` / `-Infinity` rather than `inf` / `-inf`. Both spellings parse - back into a float, and `Infinity` is what Trino, its JDBC driver and - PostgreSQL emit; the ODBC spec defines no textual form for a non-finite - float. `NaN` is unchanged. This follows a `stackable-odbc-core` change to its - shared coercion path. - -- `SQLSetStmtAttr(SQL_ATTR_CURSOR_TYPE)` with an unsupported cursor type now - substitutes `SQL_CURSOR_FORWARD_ONLY` and returns `SQL_SUCCESS_WITH_INFO` - with SQLSTATE `01S02` ("option value changed"), where it previously failed - with `HYC00`. The substituted value is readable back through - `SQLGetStmtAttr`, which is how an application learns what it was given. This - follows a `stackable-odbc-core` change; the driver's behaviour is unchanged - beyond what it reports. - -- `SQLSetConnectAttr(SQL_ATTR_TXN_ISOLATION)` refuses any level other than - `SQL_TXN_SERIALIZABLE` with SQLSTATE `HY024`, where it previously stored - whatever it was given and echoed it back. Serializable is the only level - SQLite runs at and the only one `SQL_TXN_ISOLATION_OPTION` advertises: READ - COMMITTED and REPEATABLE READ are not SQLite concepts, and READ UNCOMMITTED - needs shared-cache mode, which `connect` does not open. An application that - asked for another level previously got `SQL_SUCCESS` and serializable - behaviour regardless, with no way to learn its request had not been honoured. +First release, so this section describes what the driver offers rather than +what changed. -- `SQL_IDENTIFIER_CASE` is now declared through the backend's - `identifier_case` hook rather than answered directly. The value is unchanged - (`SQL_IC_MIXED`): SQLite stores an unquoted identifier as written and matches - it case-insensitively. Answering it in one place removes the possibility of - the hook and the direct answer disagreeing. - -- `SQL_GETDATA_EXTENSIONS` is no longer answered by this driver. The value is - unchanged, and is now `stackable-odbc-core`'s to state: it describes what - core's own fetch path supports, not anything about SQLite, and this driver - could not keep it correct if that path changed. - -- `SQL_CURSOR_COMMIT_BEHAVIOR` now reports `SQL_CB_PRESERVE` instead of - `SQL_CB_DELETE`, and `SQL_CURSOR_ROLLBACK_BEHAVIOR` is now declared rather - than left to a fallback. Both report `SQL_CB_PRESERVE`. The driver - materialises every result set eagerly, so no SQLite statement is live when - `SQLEndTran` runs and neither commit nor rollback can disturb an open cursor. - The previous `SQL_CB_DELETE` was never accurate: `stackable-odbc-core` - advertised it and implemented nothing, so the driver reported that it - destroyed cursors on commit while in fact preserving them. - -- Connections now enable foreign key enforcement: `connect` issues - `PRAGMA foreign_keys = ON`. SQLite leaves it off for backward compatibility, - and the bundled library only happened to be compiled with - `SQLITE_DEFAULT_FOREIGN_KEYS` — a property of one dependency's build rather - than of SQLite. On the current build this changes nothing; it stops - referential integrity from turning itself off if that dependency ever - changes. - -- `SQL_INTEGRITY` now reports `"Y"` instead of `"N"`. SQLite implements the - whole Integrity Enhancement Facility — `PRIMARY KEY`, `UNIQUE`, `NOT NULL`, - `CHECK`, `DEFAULT`, and `FOREIGN KEY` with referential actions — and, with - the pragma above, the driver enforces all of it. `"N"` was - `stackable-odbc-core`'s default, and the earlier justification for keeping - it — that SQLite leaves foreign keys off unless asked — no longer applies now - that the driver asks. A test exercises each constraint and an - `ON DELETE CASCADE` through `connect`. - -- `SQL_SQL_CONFORMANCE` now reports `0` — no SQL-92 level claimed — instead of - `SQL_SC_SQL92_ENTRY`. That value came from a `stackable-odbc-core` default - rather than any assessment of SQLite, and it contradicted this driver's own - answers: the spec ties entry level to `SQL_GB_GROUP_BY_EQUALS_SELECT`, while - SQLite accepts a bare non-aggregated column absent from `GROUP BY` and a - `GROUP BY` column absent from the select list. Raising the claim later means - auditing entry-level conformance properly. - -- `SQL_ALTER_TABLE` additionally reports `SQL_AT_ADD_CONSTRAINT`. Despite its - name that bit means "`ADD COLUMN` is supported with column constraints", and - SQLite accepts `NOT NULL`, `CHECK`, `REFERENCES` and a named `CONSTRAINT` on - an added column; only `UNIQUE` and `PRIMARY KEY` are refused. The bit was - unavailable when this driver first set the bitmap. - -- `SQL_SUBQUERIES`, `SQL_COLUMN_ALIAS`, `SQL_CONCAT_NULL_BEHAVIOR`, - `SQL_UNION`, `SQL_CONVERT_FUNCTIONS`, `SQL_ORDER_BY_COLUMNS_IN_SELECT`, - `SQL_ACCESSIBLE_TABLES`, `SQL_DATA_SOURCE_READ_ONLY` and - `SQL_SEARCH_PATTERN_ESCAPE` are now stated by this driver rather than - inherited from `stackable-odbc-core`, which had no way to know most of them. - Every value was verified against the bundled library; only `SQL_SUBQUERIES` - changed (see `Fixed`). - -- `SQL_GROUP_BY`, `SQL_NULL_COLLATION`, `SQL_CORRELATION_NAME`, - `SQL_NON_NULLABLE_COLUMNS`, `SQL_EXPRESSIONS_IN_ORDERBY`, - `SQL_TIMEDATE_ADD_INTERVALS` and `SQL_TIMEDATE_DIFF_INTERVALS` are now stated - by this driver rather than inherited. `SQL_CORRELATION_NAME` - (`SQL_CN_ANY`), `SQL_NON_NULLABLE_COLUMNS` (`SQL_NNC_NON_NULL`) and - `SQL_EXPRESSIONS_IN_ORDERBY` (`"Y"`) had never been asserted anywhere; the - two interval bitmaps report `0`, matching `SQL_TIMEDATE_FUNCTIONS`, which - does not claim `TIMESTAMPADD` or `TIMESTAMPDIFF`. - -- `SQL_TXN_ISOLATION_OPTION` now reports `SQL_TXN_SERIALIZABLE` alone, instead - of also advertising `SQL_TXN_READ_UNCOMMITTED`, `SQL_TXN_READ_COMMITTED` and - `SQL_TXN_REPEATABLE_READ`. Transactions in SQLite are serializable; READ - COMMITTED and REPEATABLE READ are not SQLite concepts, and READ UNCOMMITTED - additionally requires shared-cache mode, which this driver never enables. - Nothing applied the level an application set in any case — - `SQL_ATTR_TXN_ISOLATION` is stored on the connection and read back unchanged - — so the three extra levels promised behaviour no code path delivered. - -- `SQL_ALTER_TABLE` now reports `SQL_AT_ADD_COLUMN_SINGLE`, - `SQL_AT_ADD_COLUMN_DEFAULT`, `SQL_AT_ADD_COLUMN_COLLATION`, - `SQL_AT_ADD_TABLE_CONSTRAINT` and `SQL_AT_CONSTRAINT_NAME_DEFINITION` instead - of `0`, which claimed SQLite cannot alter a table in any way. Each bit is - verified by executing the clause against the bundled library, and the bits - that stay off are verified to be rejected by it. SQLite's unqualified - `DROP COLUMN` and `DROP CONSTRAINT`, and both `RENAME` forms, remain absent - from the bitmap: the ODBC value has no bit for them, and its `CASCADE` and - `RESTRICT` variants are syntax errors in SQLite. - -- `SQL_OUTER_JOIN_CAPABILITIES` now reports every outer-join form SQLite - implements — `SQL_OJ_LEFT`, `SQL_OJ_RIGHT`, `SQL_OJ_FULL`, `SQL_OJ_NESTED`, - `SQL_OJ_NOT_ORDERED`, `SQL_OJ_INNER` and `SQL_OJ_ALL_COMPARISON_OPS` — instead - of `0`. It previously inherited `stackable-odbc-core`'s default of `0`, which - said SQLite supports no outer joins at all while this driver's own - `SQL_OUTER_JOINS` reported `"Y"`. Each bit is verified by executing the join - it describes against the bundled library, not assumed from release notes. - -### Fixed - -- `SQLRowCount` reported `0` after a `CREATE TABLE`, `DROP TABLE`, `ALTER - TABLE`, `BEGIN`, `COMMIT`, `PRAGMA` or `VACUUM`, where the spec's - affected-row count does not apply at all. The three answers are now distinct: - a count for a searched INSERT / UPDATE / DELETE, the materialised size of a - result set, and *no count* for everything else. This matters beyond - tidiness — `stackable-odbc-core` reads a zero-column statement reporting a - counted zero as `SQL_NO_DATA`, per `SQLExecDirect`'s Comments, so every DDL - statement this driver ran returned `SQL_NO_DATA` to the application instead - of `SQL_SUCCESS`. A searched DELETE that matches nothing still reports `0`, - which is the case the spec reserves `SQL_NO_DATA` for. - - The same fix removes a stale count: `sqlite3_changes()` reports the rows - touched by the *most recently completed* INSERT, UPDATE or DELETE, so a - `CREATE TABLE` run straight after a three-row `INSERT` was handed that `3` - and reported it. - -- `SQLForeignKeys` reported `PKCOLUMN_NAME` as NULL for a foreign key declared - without an explicit column list (`REFERENCES parent`), a column the spec - marks "not NULL". SQLite defines the implicit target as the parent table's - primary key, so the name is now resolved from it — per position, for a - composite key — rather than dropped. `PRAGMA foreign_key_list` leaves its - `to` column NULL in that case, which is what the old code passed straight - through. - -- `SQLStatistics` with a null `TableName` returned `SQL_SUCCESS` and an empty - result set, which an application reads as "that table has no indexes". It is - now `HY009`. `SQLStatistics` is one of only two catalog functions whose - null-`TableName` clause carries no **(DM)** marker, so the driver owns it - rather than the Driver Manager. An empty-string `TableName` is still a legal - argument naming no table, and still returns no rows. - -- Every catalog result set is now sorted into the order its spec page - mandates, by `stackable-odbc-core`, which holds the rows. `SQLTables`, - `SQLColumns`, `SQLPrimaryKeys`, `SQLForeignKeys` and `SQLSpecialColumns` were - previously returned in whatever order the underlying `sqlite_master` or - `PRAGMA` query produced, which matched the spec only by accident; - `SQLStatistics` sorted itself. Integer key columns (`KEY_SEQ`, - `ORDINAL_POSITION`) compare numerically, so a table with more than nine - columns no longer sorts column 10 before column 2. - -- `SQLDescribeCol` reported `2^64 - 4` as the column size of an unbounded - column instead of `0`, `SQLGetInfoW` wrote four bytes into the two-byte - buffer an application supplies for four `SQLUSMALLINT` info types, and a - parameter bound `SQL_PARAM_OUTPUT` had its buffer read as an input value. - `SQLAllocHandle`, `SQLFreeHandle` and `SQLFreeStmt` now post a diagnostic on - failure rather than returning a bare `SQL_ERROR` with nothing for - `SQLGetDiagRec` to report. All follow `stackable-odbc-core` fixes. - -- `SQL_MAX_COLUMNS_IN_SELECT`, `_IN_TABLE`, `_IN_GROUP_BY`, `_IN_ORDER_BY`, - `_IN_INDEX`, `SQL_MAX_STATEMENT_LEN` and `SQL_MAX_ROW_SIZE` now report the - connection's actual limits instead of `0`. The spec allows `0` for "no - specified limit or the limit is unknown", and `stackable-odbc-core` answers - that because it cannot know — but SQLite enforces real limits, and a tool - deciding whether to chunk a wide `SELECT` or a long `IN` list reads exactly - these. They are read per connection through `sqlite3_limit` rather than - hardcoded, because `sqlite3_limit` also *sets* them, so any constant would be - wrong for a connection that changed one. `SQL_MAX_TABLES_IN_SELECT` stays `0`: - SQLite's 64-table join cap has no `sqlite3_limit` to read it from, and - transcribing the constant is what has gone stale twice in this crate. - -- `SQL_MAX_CATALOG_NAME_LEN` and `SQL_MAX_SCHEMA_NAME_LEN` now report `0` - instead of the generic identifier length. This driver supports neither - catalogs nor schemas, so there is no name for these to bound; they were - stating a maximum length for something the same driver says does not exist. - Both answers are derived from `supports_catalogs` / `supports_schemas` rather - than pinned to `0`, so they stay right if either hook flips — and because - those hooks are per-connection, the `0` applies once a connection is open. - Asked before `SQLDriverConnectW`, both fall through to - `stackable-odbc-core`'s generic identifier length, the same answer it gives - pre-connect for every other `SQL_MAX_*_NAME_LEN`. - -- `SQL_SUBQUERIES` no longer claims `SQL_SQ_QUANTIFIED`. `< ALL`, `< ANY` and - `< SOME` are all syntax errors in SQLite, which this driver already recorded - by excluding `SQL_SP_QUANTIFIED_COMPARISON` from `SQL_SQL92_PREDICATES` — so - the same capability was denied by one info type and advertised by another, - the advertised half coming from a `stackable-odbc-core` default. A tool - reading `SQL_SUBQUERIES` would have pushed down a predicate SQLite rejects. - -- `SQL_KEYWORDS` now lists SQLite's own keywords instead of an empty string. - The list is read out of the linked library through `sqlite3_keyword_count` / - `sqlite3_keyword_name` rather than transcribed from SQLite's documentation, - so it describes the library the driver links. An empty list claimed SQLite - has no keywords of its own — it has `AUTOINCREMENT`, `PRAGMA`, `VACUUM`, - `GLOB`, `REGEXP` and many more, and applications read this to decide which - identifiers need quoting. The driver reports the raw list through - `Backend::keywords`; `stackable-odbc-core` subtracts the ODBC reserved words - the specification defines this value as excluding. - -- `{fn CURRENT_DATE()}`, `{fn CURRENT_TIME()}` and `{fn CURRENT_TIMESTAMP()}` - now execute. `SQL_TIMEDATE_FUNCTIONS` advertised all three, but nothing - translated them: SQLite spells them as bare keywords, `SELECT CURRENT_DATE();` - is a syntax error, and a name-only remap cannot drop the trailing `()` the - ODBC escape always carries — so each reached SQLite as `CURRENT_DATE()` and - failed to prepare. The driver was advertising three functions an application - could not use. `stackable-odbc-core`'s new - `EscapeDialect::rewrite_scalar_fn` replaces the whole escape, which is what - emitting a bare keyword requires. - -- `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR` and `SQL_SCHEMA_TERM` now - report empty strings instead of `"catalog"`, `"."` and `"schema"`. The - `SQLGetInfo` specification requires an empty string from all three when the - data source supports neither catalogs nor schemas, which this driver has - always declared through `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION`, - `SQL_CATALOG_USAGE` and `SQL_SCHEMA_USAGE`. Applications were told catalogs do - not exist and given their name in the same breath. All seven values now derive - from a single `SUPPORTS_CATALOGS` / `SUPPORTS_SCHEMAS` pair, and a test asserts - they agree. +### Added -- `SQLCloseCursor` after a statement that produced no result set now returns - `24000` rather than succeeding. An `INSERT` opens no cursor, so there is - nothing to close; the call was accepted because cursor state was inferred - from whether a backend statement existed. +**Querying.** An ODBC 3.80 driver for [SQLite](https://sqlite.org) on Linux and +Windows. Queries, result sets fetched a row at a time, bound parameters, and +the ODBC escape sequences `{fn ...}`, `{d ...}` and `{oj ...}` translated into +SQLite SQL. SQLite is compiled into the driver, so there is no separate library +to install and no second copy on the machine that could disagree with it. + +**Types.** SQLite is dynamically typed and has no date, time or boolean type at +all. The driver reads each column's declared type alongside the storage class +of the values in it, and maps the pair onto a proper ODBC type. That covers the +three ways SQLite users store a timestamp: ISO 8601 text, Unix epoch seconds, +and Julian day numbers. + +**Metadata.** Tables, views, columns, primary keys, foreign keys, indexes and +row identifiers, read from SQLite's own `PRAGMA` introspection and +`sqlite_master`. A tool can browse the database instead of asking you to type +table names. + +**Transactions.** Turn autocommit off and the driver opens a transaction, then +commits or rolls back on request and opens the next one. Open result sets +survive both, because every row has already been read into memory by the time +you commit. + +**Foreign keys.** SQLite enforces foreign keys only when asked, which surprises +most people who assume a `REFERENCES` clause is a rule the database keeps. The +driver issues `PRAGMA foreign_keys = ON` for every connection. + +**Cancellation.** `SQLCancel` from another thread calls `sqlite3_interrupt` on +the connection, so a runaway query stops instead of running to completion while +the application believes it was cancelled. The statement reports `HY008` and +can be run again. + +**Reported capabilities.** What a driver says about itself is how applications +decide which SQL to send, so the values here are measured rather than +transcribed. The `ALTER TABLE` clauses are established by executing each one +against the linked library, and the reserved-word list is read out of it at +runtime through `sqlite3_keyword_name`. + +**Packaging.** Installers for Linux and Windows, and a Windows dialog for +creating a data source, reachable from the ODBC Data Source Administrator's +**Add…** button. Every release artifact ships with a CycloneDX SBOM, is +published alongside an SPDX document, and is covered by `sha256sums.txt`. The +SBOM is generated from the binary's own embedded dependency list, so it +describes what was linked, including the bundled SQLite. + +### Known limitations + +- SQLite has no catalogs and no schemas, so the driver reports none rather than + inventing a one-level hierarchy. +- SQLite has no stored procedures, so those lookups return no rows. +- Rows are fetched one at a time. `SQL_ATTR_ROW_ARRAY_SIZE` and + `SQL_ATTR_PARAMSET_SIZE` are both pinned at 1, so there are no block cursors + and no parameter arrays. +- `SQL_ATTR_QUERY_TIMEOUT` is reported as unsupported. A running statement can + still be cancelled from another thread. +- Result sets are read into memory in full, which is what lets cursors survive + a commit or rollback. A `SELECT` larger than available memory will not work. +- Only the serializable isolation level is offered, because it is the only one + SQLite provides. Asking for a weaker one is refused rather than silently + ignored. +- Linux has no setup dialog. unixODBC has no convention for a driver to display + one, so a data source there is a section in `odbc.ini`. [Unreleased]: https://github.com/stackabletech/stackable-odbc-sqlite/commits/HEAD diff --git a/CLAUDE.md b/CLAUDE.md index 67a369a..cd1414d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # Project Rules -Read and follow @AGENTS.md, which contains architecture, patterns, and procedures. +Read and follow @AGENTS.md. It holds the architecture, the patterns and the +procedures, and it is where the reasoning behind every rule below lives. ## Non-Negotiable Rules @@ -9,33 +10,34 @@ Read and follow @AGENTS.md, which contains architecture, patterns, and procedure `stackable-odbc-core`, but what this driver returns from `get_info`, `get_info_raw`, the catalog functions and the type-conversion paths is directly observable by applications, and each has a spec-defined shape and - value range. Never claim a SQLSTATE or an info value is wrong without checking - the actual spec table first. Pay attention to **(DM)** annotations: those - SQLSTATEs are returned by the Driver Manager, not the driver. -- **Route every client error through `map_sqlite_error`.** Never hand-build an - `OdbcError` or `SqliteError` from a `rusqlite::Error` at the call site; that - function is the single place that decides the SQLSTATE. A new classified - variant must carry the originating error in its `cause` field, or the - diagnostic reports native code `0`. + value range. Never claim a SQLSTATE or an info value is wrong without + checking the actual spec table first. Pay attention to **(DM)** annotations: + those SQLSTATEs are returned by the Driver Manager, not the driver. +- **Route every client error through `map_sqlite_error`.** It is the single + place that decides the SQLSTATE, and a new classified variant must carry the + originating error in its `cause` field. See + [Backend error mapping](AGENTS.md#backend-error-mapping). - **One error type.** Every `Backend` and `StatementBackend` method returns `Result<_, SqliteError>`. An `OdbcError` core produced travels back through `SqliteError::Odbc` via `.into()`. Never reclassify it, which would discard the SQLSTATE core chose. - **Declare each capability once.** A `SQLGetInfo` value with a `Backend` hook - is answered through the hook only, never also in `get_info_raw`. Two answers - are a value that can disagree with itself. -- **Use `odbc-sys` types.** Never redefine enums, structs, or constants it - already provides. Reach them through `stackable_odbc_core::types`, or through - `stackable_odbc_core::odbc_sys` for anything `types` does not re-export. Do - **not** add `odbc-sys` as a direct dependency, and do not hand-roll a - `#[repr(C)]` mirror of one of its structs. -- **Convert raw integers to typed enums at the boundary.** Use the - `xxx_from_raw()` functions from core, never `transmute`. -- **Do not make result-set fetching lazy.** `exec_direct` materialises every row - before returning, and two reported ODBC capabilities + is answered through the hook only, never also in `get_info_raw`. See + [Declaring capabilities](AGENTS.md#declaring-capabilities). +- **Use `odbc-sys` types**, re-exported from `stackable_odbc_core::types`, or + from `stackable_odbc_core::odbc_sys` for anything `types` does not re-export. + Never redefine what it provides, never add an `odbc-sys` dependency to this + crate's `Cargo.toml`, and never hand-roll a `#[repr(C)]` mirror of one of its + structs. See [Named constants](AGENTS.md#named-constants). +- **Convert raw integers to typed enums at the boundary** with core's + `xxx_from_raw()` functions, never `transmute`. +- **Do not make result-set fetching lazy.** `exec_direct` materialises every + row before returning, and two reported ODBC capabilities (`SQL_CURSOR_COMMIT_BEHAVIOR`, `SQL_CURSOR_ROLLBACK_BEHAVIOR`) are only - correct because of it. See the Transactions section of AGENTS.md. -- **Run `pre-commit run --all-files`** before every commit. This is the single + correct because of it. See + [Result sets are materialised eagerly](AGENTS.md#result-sets-are-materialised-eagerly) + and [Transactions](AGENTS.md#transactions). +- **Run `pre-commit run --all-files` before every commit.** It is the single source of truth for what must pass. ## Scope @@ -48,21 +50,17 @@ Read and follow @AGENTS.md, which contains architecture, patterns, and procedure Never read entire files by default. Survey, locate, then extract. -1. **Survey first.** Check file size before reading (`stat -c%s file`). Files - >50 KB must be sliced, not read whole. `src/ffi_integration_tests.rs` (~5100 - lines), `src/backend/info.rs` (~2500), `src/backend.rs` (~1700), - `src/backend/metadata.rs` (~1500) and `src/type_conversion.rs` (~1000) are - all well over that. -2. **Navigate definitions with ctags.** Run `ctags -R .` once to build a tags - index, then `grep "^SymbolName" tags` to find the exact file and line of any - function, struct, or trait, with no file reading needed. -3. **Locate with Grep.** Find patterns, keywords, or usages before reading. Use - `-C` for context lines. -4. **Extract with Read (offset + limit).** Once you know the line range, read - only that slice. -5. **Structured data.** Use `jq` for JSON, `yq` for YAML; never read raw markup - whole. -6. **Filesystem survey.** Use `tree -L 2 -I '.git|target|node_modules'` instead - of recursive `ls`. -7. **Verify edits with diff.** After editing, `git diff -u` to confirm changes - instead of re-reading. +1. Survey first. Check the file size with `stat -c%s file` before reading it. + Anything over 50 KB must be sliced, not read whole; several modules in + `src/` are. +2. Navigate definitions with ctags. Run `ctags -R .` once to build the index, + then `grep "^SymbolName" tags` for the exact file and line of any function, + struct or trait. No file reading needed. +3. Locate with Grep. Find patterns, keywords or usages before reading. Use `-C` + for context lines. +4. Extract with Read, using `offset` and `limit` once you know the line range. +5. Read structured data with a tool that understands it: `jq` for JSON, `yq` + for YAML. Never read raw markup whole. +6. Survey the filesystem with `tree -L 2 -I '.git|target|node_modules'`, not a + recursive `ls`. +7. Verify edits with `git diff -u` rather than re-reading the file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e7c599..a09807d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,8 +91,7 @@ points against real handles, so it catches marshalling bugs an ordinary Rust test cannot. The integration suite goes one layer further out, through real unixODBC using -Python's `pyodbc`. It needs no server, so unlike the Trino driver's suite it -runs on every pull request: +Python's `pyodbc`. It needs no server, so it runs on every pull request: ```bash ./integration-tests/setup.sh # build the driver, create the database, write the ODBC config @@ -120,11 +119,13 @@ Two more things a change usually needs: [`CHANGELOG.md`](CHANGELOG.md), if an ODBC application can observe the difference. A changed SQLSTATE, a changed `SQLGetInfo` value, a new connection-string key or a different type mapping all count. -- **A new connection-string key means three edits**: the parser in - `src/backend/types/connect_params.rs`, the table in [`README.md`](README.md), - and the `$Fields` table in `packaging/windows/configure-dsn.ps1`. +- **A new connection-string key touches four places**: the parser in + `src/backend/types/connect_params.rs`, the key tables in + [`README.md`](README.md) and [`packaging/README.md`](packaging/README.md), and + the `$Fields` table in `packaging/windows/configure-dsn.ps1`. `dsn_keys_match_the_connection_string_parser` in `src/lib.rs` fails the build - if the parser and the dialog disagree. + if the parser and the dialog disagree. See + [Connection string keys](AGENTS.md#connection-string-keys). ## Where things live diff --git a/README.md b/README.md index 8f1cac9..e85a118 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/stackabletech/stackable-odbc-sqlite/badge)](https://scorecard.dev/viewer/?uri=github.com/stackabletech/stackable-odbc-sqlite) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](https://docs.stackable.tech/home/stable/contributor/index.html) [![Apache License 2.0](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE) -[![ODBC 3.80](https://img.shields.io/badge/ODBC-3.80-blue)](#what-it-deliberately-does-not-do) -[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20Windows-blue)](#quick-start) +[![ODBC 3.80](https://img.shields.io/badge/ODBC-3.80-blue)](#compatibility) +[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20Windows-blue)](#compatibility) [![SQLite bundled](https://img.shields.io/badge/SQLite-3.53.2%20bundled-blue)](https://sqlite.org) [Stackable Data Platform](https://stackable.tech/) | [Platform Docs](https://docs.stackable.tech/) | [Discussions](https://github.com/orgs/stackabletech/discussions) | [Discord](https://discord.gg/7kZ3BNnCAF) @@ -22,83 +22,55 @@ ## What is this? [SQLite](https://sqlite.org) is a database that lives in a single file. There -is nothing to install and nothing to start: the whole database is one `.db` -file you can copy onto a USB stick. Your phone is running several of them right -now. +is nothing to install and nothing to start, because the whole database is one +`.db` file you can copy onto a USB stick. Your phone is running several of them +right now. Most desktop tools cannot open one of those files directly, but nearly all of -them speak **ODBC**. ODBC is a widely supported standard: a tool loads a small library called a *driver*, calls a fixed set of functions on it, and the driver translates those calls into whatever the actual database understands. Write one driver, and every ODBC-speaking tool on the machine can talk to that database. +them speak **ODBC**, a standard that lets any tool load a small library, called +a driver, and talk to a database through it. -This repository is that driver for SQLite. Install it, and Excel, LibreOffice -Base, DBeaver, `isql` and Python's `pyodbc` can query a SQLite file as if it -were a full database server. Linux and Windows are both supported. +This is the ODBC driver for SQLite. Install it, and Excel, LibreOffice Base, +DBeaver, `isql` and Python's `pyodbc` can query a SQLite file as if it were a +full database server. Linux and Windows are both first-class targets. -Two things make it unusual: - -- **It carries its own SQLite.** Version 3.53.2 is compiled straight into the - driver, so there is no separate SQLite to install and no version of it on the - machine that could disagree with the one the driver actually uses. -- **It is a testbed.** Everything generic about being an ODBC driver lives in - [`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core), - which also powers the - [Trino driver](https://github.com/stackabletech/stackable-odbc-trino). SQLite - is small, fast and needs no server, which makes it the ideal backend for - proving that shared framework behaves. +SQLite itself is compiled into the driver, so there is nothing else to install +and no second copy on the machine that could disagree with it. ## Quick start -No release has been cut yet, so build the driver yourself. You need Rust (the -version in `rust-toolchain.toml` is installed automatically by `rustup`) and -the unixODBC development headers, because the ODBC bindings link against them: +Download an archive from the +[releases page](https://github.com/stackabletech/stackable-odbc-sqlite/releases). -```bash -sudo apt-get install unixodbc-dev # Debian/Ubuntu -sudo pacman -S unixodbc # Arch -``` +### Windows -Clone this repository: +1. Unzip `stackable-odbc-sqlite-<version>-windows-x64.zip`. +2. Right-click `install.bat` and choose **Run as administrator**. This registers + the driver with Windows. +3. Open **ODBC Data Sources (64-bit)** from the Start menu, click **Add**, and + pick `stackable_odbc_sqlite` from the list. Name the data source, browse to + your `.db` file, and click **Test connection** before saving. -```bash -git clone https://github.com/stackabletech/stackable-odbc-sqlite -cd stackable-odbc-sqlite -cargo build --release -``` +Step 3 creates a *DSN*: a saved connection with a name. Once it exists, every +tool on the machine can pick it from a list instead of asking you to type a +connection string. -Output: `target/release/libstackable_odbc_sqlite.so`. +### Linux -For Windows, cross-compile with MinGW (`gcc-mingw-w64-x86-64`): +You need unixODBC (the `unixodbc` package). Installing the driver registers it +system-wide, so it needs root. ```bash -rustup target add x86_64-pc-windows-gnu -cargo build --release --target x86_64-pc-windows-gnu +mkdir /tmp/sqlite-odbc +tar xzf stackable-odbc-sqlite-<version>-linux-x64.tar.gz -C /tmp/sqlite-odbc +cd /tmp/sqlite-odbc +sudo ./install.sh ``` -Output: `target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll`. - -### Installing it - -`packaging/build-archives.sh` turns those binaries into the same release -archives CI publishes, each with an installer inside: - -```bash -VERSION=0.0.1 ./packaging/build-archives.sh -``` - -On Linux, unpack `stackable-odbc-sqlite-<version>-linux-x64.tar.gz` and run -`sudo ./install.sh`. It copies the library into place and registers it with -unixODBC; check it worked with `odbcinst -q -d`, which should list +Check it worked with `odbcinst -q -d`, which should list `[stackable_odbc_sqlite]`. -On Windows, unpack the `.zip` and run `install.bat` from an Administrator -Command Prompt, then look for `stackable_odbc_sqlite` on the Drivers tab of -**ODBC Data Sources (64-bit)**. From there, **Add…** opens the driver's own -dialog: name the data source, browse to a `.db` file, and press **Test -connection** to check it before saving. - -The full install, uninstall and DSN reference is in -[`packaging/README.md`](packaging/README.md). - -### Then use it +### Your first query ```python import pyodbc @@ -108,66 +80,13 @@ for row in conn.cursor().execute("SELECT name FROM sqlite_master WHERE type = 't print(row.name) ``` -Or straight from a source checkout, without installing anything at all: - -```bash -isql -3 -k "Driver=$(pwd)/target/release/libstackable_odbc_sqlite.so;Database=$(pwd)/test/test.db" -v -``` - -## Highlights - -- **The stop button actually stops the query.** Cancelling from your tool calls - SQLite's `sqlite3_interrupt` on the connection, so a runaway query really - stops instead of quietly running to the end while your tool pretends it was - cancelled. The statement reports "operation canceled" and can be re-run. - -- **Real transactions.** Turn autocommit off and the driver opens a transaction - for you, then commits or rolls back when you say so and immediately opens the - next one. Your open result sets survive both, because the driver has already - read every row into memory by the time you commit. - -- **Foreign keys are switched on.** SQLite ships with foreign-key enforcement - *off* for backwards compatibility, which surprises almost everyone. This - driver turns it on for every connection, so a `REFERENCES` clause in your - schema is a rule the database enforces rather than a comment. - -- **Your tool can browse the database.** Tables, views, columns, primary keys, - foreign keys, indexes and row identifiers all show up in the object browser, - read out of SQLite's own `PRAGMA` introspection. So you can click through what - is there instead of guessing table names. - -- **Columns get sensible types even though SQLite has almost none.** SQLite is - dynamically typed: any value can go in any column, and there is no `DATE` or - `BOOLEAN` type at all. The driver reads each column's declared type and its - actual storage class and maps them onto proper ODBC types, including the - three different ways SQLite people store a timestamp (ISO text, Unix seconds, - Julian day numbers). - -- **Nothing is claimed that was not measured.** What a driver reports about - itself is how tools decide which SQL to send, so guessing wrong there breaks - things in confusing ways. The tests here run the actual SQL to check: the list - of `ALTER TABLE` clauses is verified by executing each one, and the list of - reserved words is read out of the linked SQLite library at runtime instead of - being copied from documentation that can drift. - -- **Windows is a real target, not an afterthought.** It gets its own installer - and its own setup dialog, so the ODBC administrator's **Add…** button works - the way it does for a commercial driver. The DLL is cross-compiled, - export-checked and unit-tested on every pull request, and the integration - suite can be run through the Windows Driver Manager in a VM, which is far - stricter than unixODBC and tends to fail silently rather than loudly. - -- **Every release says what is inside it.** Both archives carry a CycloneDX - SBOM generated from the binary's own embedded dependency list rather than - from `Cargo.toml`, so it describes what was linked. That includes the - bundled SQLite and the Driver Manager the library loads, neither of which - cargo can see. The release page also carries SPDX, checksums and build - provenance attestations. +For the full install and uninstall reference, see +[`packaging/README.md`](packaging/README.md). ## Connecting Connection strings are `Key=Value` pairs joined by `;`. Keys are -case-insensitive. There is exactly one key: +case-insensitive. There is exactly one key. | Key | Required | Meaning | |-----|----------|---------| @@ -177,8 +96,8 @@ case-insensitive. There is exactly one key: Driver=stackable_odbc_sqlite;Database=/path/to/your.db ``` -Instead of typing that every time you can save it as a **DSN**, which is just a -named, stored connection, like a browser bookmark. On Linux, add a section to +Instead of typing that every time you can save it as a DSN, which is a named, +stored connection much like a browser bookmark. On Linux, add a section to `~/.odbc.ini`: ```ini @@ -187,83 +106,130 @@ Driver = stackable_odbc_sqlite Database = /path/to/your.db ``` -On Windows, the **Add…** button in the ODBC Data Source Administrator writes -one for you; see [`packaging/README.md`](packaging/README.md) for that and for -the scripted alternatives. +On Windows the **Add** button in the ODBC Data Source Administrator writes one +for you. See [`packaging/README.md`](packaging/README.md) for that and for the +scripted alternatives. -### Logging +## What you get -Two environment variables turn on tracing, which is by far the fastest way to -see which ODBC functions your tool actually calls, and in what order: +- **The stop button stops the query.** Cancelling from your tool calls SQLite's + `sqlite3_interrupt` on the connection, so a runaway query really stops rather + than running to the end while your tool reports it as cancelled. The + statement can be run again afterwards. -```bash -# Levels: trace, debug, info, warn, error -ODBC_LOG_LEVEL=debug isql -3 test_sqlite -v +- **Real transactions.** Turn autocommit off and the driver opens a transaction + for you, then commits or rolls back when you say so and immediately opens the + next one. Your open result sets survive both, because the driver has already + read every row into memory by the time you commit. -# Or send it to a file instead of stderr -ODBC_LOG_LEVEL=debug ODBC_LOG_FILE=/tmp/odbc.log isql -3 test_sqlite -v -``` +- **Foreign keys are switched on.** SQLite ships with foreign-key enforcement + *off* for backwards compatibility, which surprises almost everyone. This + driver turns it on for every connection, so a `REFERENCES` clause in your + schema is a rule the database keeps. -## What it deliberately does not do +- **Your tool can browse the database.** Tables, views, columns, primary keys, + foreign keys, indexes and row identifiers all show up in the object browser, + read from SQLite's own `PRAGMA` introspection, so you can click through what + is there instead of guessing table names. -Every one of these is reported to the application as unsupported rather than -quietly faked, so a tool can react to it instead of trusting a wrong answer. +- **Columns get sensible types even though SQLite has almost none.** SQLite is + dynamically typed. Any value can go in any column, and there is no `DATE` or + `BOOLEAN` type at all. The driver reads each column's declared type together + with the storage class of its values and maps the pair onto a proper ODBC + type. That covers the three ways people store a timestamp in SQLite: ISO + text, Unix seconds and Julian day numbers. + +- **Your tool gets accurate answers about what SQLite supports.** Applications + choose which SQL to send based on what the driver reports about itself, so + those answers are measured against the bundled library rather than copied + from documentation. The `ALTER TABLE` clauses are checked by executing each + one, and the reserved-word list is read out of the library at runtime. + +- **Windows gets its own installer and setup dialog**, so the ODBC + administrator's **Add** button behaves the way it does for a commercial + driver. The DLL is cross-compiled, export-checked and unit-tested on every + pull request, and the integration suite can also be run through the Windows + Driver Manager in a VM. + +- **Every release says what is inside it.** Both archives carry a CycloneDX + SBOM generated from the binary's own embedded dependency list rather than + from `Cargo.toml`, so it describes what was actually linked, including the + bundled SQLite. The release page also carries SPDX documents, checksums and + build provenance attestations. + +## Limits + +Each of these is reported to your tool as unsupported rather than quietly +faked, so the tool can react instead of trusting a wrong answer. - **No catalogs and no schemas.** SQLite has neither, so the driver says so - rather than inventing a fake one-level hierarchy for the sake of looking - familiar. + rather than inventing a one-level hierarchy for the sake of looking familiar. - **No stored procedures.** SQLite has none, so those lookups return nothing. +- **Rows arrive one at a time.** There are no block cursors and no parameter + arrays. - **No query timeout.** You can cancel a running statement from another thread, - but asking for "give up after 30 seconds" is answered with "you have no - timeout" and a warning, instead of a promise that would never be kept. -- **Result sets are read into memory in one go.** Simple, and it is what makes - cursors survive a commit or rollback, but a `SELECT` over a table larger than - your RAM is not going to work. + but "give up after 30 seconds" is answered with a warning rather than a + promise that would never be kept. +- **Result sets are read into memory in one go.** That is what lets cursors + survive a commit or rollback, but a `SELECT` over a table larger than your + RAM will not work. - **One isolation level.** SQLite gives you serializable transactions, so that - is the only level offered, and asking for a weaker one is refused up front - rather than accepted and silently ignored. -- **No setup dialog on Linux.** Windows gets one, from the **Add** button in - the ODBC administrator. unixODBC has no equivalent convention for a driver to - put a window on the screen, so on Linux a DSN is a section in `odbc.ini`. + is the only level offered, and asking for a weaker one is refused up front. +- **No setup dialog on Linux.** unixODBC has no convention for a driver to put + a window on the screen, so a DSN there is a section in `odbc.ini`. -## Testing +## Compatibility -```bash -cargo test # unit and FFI tests; needs no database file and no setup -cargo bench # Criterion fetch-throughput benchmark against :memory: -``` +| | | +|---|---| +| ODBC | 3.80 | +| Platforms | Linux x86-64, Windows x86-64 | +| Driver Managers | unixODBC, and the Windows Driver Manager | +| SQLite | 3.53.2, compiled into the driver | +| Tested with | `pyodbc`, `isql` | -`cargo test` drives the real exported C entry points against real handles, so -it catches the marshalling bugs that ordinary Rust tests cannot. +## Troubleshooting -The integration suite goes one layer further out and runs through real -unixODBC, using Python's `pyodbc` exactly like a normal application would: +**Turn on logging first.** The driver logs to a file when you ask it to, and +that is usually enough to see what a tool is really sending: ```bash -./integration-tests/setup.sh # build the driver, create the database, write the ODBC config -./integration-tests/run-tests.sh # run the pyodbc suite, then cargo test +export ODBC_LOG_LEVEL=debug # trace, debug, info, warn, error +export ODBC_LOG_FILE=/tmp/sqlite-odbc.log ``` -Both are run on every pull request. `run-tests.sh --windows` additionally runs -the same suite inside a Windows VM; see -[integration-tests/README.md](integration-tests/README.md) for what is covered -and [integration-tests/windows/WINDOWS.md](integration-tests/windows/WINDOWS.md) -for how to provision one. +On Windows, set the same two as environment variables. The log may contain your +SQL, so check it before sharing. + +**You connected fine but the database is empty.** SQLite creates a file that +does not exist yet rather than refusing, so a typo in the path connects +successfully and finds nothing. Check the path. The Windows dialog's **Test +connection** button reports the table count for exactly this reason. + +**The driver does not appear in the list.** On Linux, run `odbcinst -q -d`; if +`[stackable_odbc_sqlite]` is missing, the install did not complete. On Windows, +make sure you opened **ODBC Data Sources (64-bit)**: a 64-bit driver is +invisible to the 32-bit Administrator, and both are in the Start menu under +similar names. -For the architecture, the conventions and the full testing reference, see -[AGENTS.md](AGENTS.md). For building it, the `[patch]` that points core at a -sibling checkout, and what has to pass before a commit, see -[CONTRIBUTING.md](CONTRIBUTING.md). +**A `REFERENCES` clause is being enforced that was not before.** That is +deliberate. The driver turns foreign-key enforcement on for every connection, +which most other SQLite tooling leaves off. -## Releasing +## Getting help -See [packaging/README.md](packaging/README.md) for building the release -archives and how the SBOM is produced, and `release.toml` for the -`cargo-release` configuration. +- [GitHub Discussions](https://github.com/orgs/stackabletech/discussions) for + questions +- [Discord](https://discord.gg/7kZ3BNnCAF) to talk to us +- [Issues](https://github.com/stackabletech/stackable-odbc-sqlite/issues) for + bugs, and [SECURITY.md](SECURITY.md) for anything security-related -## Security +## Contributing -Please report vulnerabilities privately; see [SECURITY.md](SECURITY.md). +See [CONTRIBUTING.md](CONTRIBUTING.md) for building from source, running the +tests, and how the repository is laid out. [AGENTS.md](AGENTS.md) has the +architecture and the ODBC design rationale behind what the driver reports. +[CHANGELOG.md](CHANGELOG.md) records what changed in each release. ## License diff --git a/benches/fetch_sqlite.rs b/benches/fetch_sqlite.rs index 224e97f..0c83fd2 100644 --- a/benches/fetch_sqlite.rs +++ b/benches/fetch_sqlite.rs @@ -5,7 +5,7 @@ //! eager-materialize + per-call clone cost in the SqliteBackend → ColumnValue //! → write_column_value pipeline. //! -//! Two workload shapes (see stackable-odbc-core/bench/benches/fetch_throughput.rs for spec): +//! Two workload shapes, matching core's own fetch-throughput benchmark: //! * Shape A: mixed columns (BENCH_ROWS × BENCH_COLS, 50/40/10 i64/str/decimal) //! * Shape B: 5 columns × BENCH_WIDE_STR_LEN-char strings (BENCH_WIDE_ROWS rows) //! @@ -127,12 +127,11 @@ unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { /// Run setup SQL on `conn` through `SQLExecDirect`. /// -/// This used to reach into `ConnectionHandle` for the underlying -/// `rusqlite::Connection` to bypass ODBC dispatch; core's `handles` module is -/// `pub(crate)` now, and the bypass bought nothing measurable anyway. Every -/// setup here is three statements (`DROP`, `CREATE` and one bulk `INSERT` -/// whose rows are generated by a recursive CTE inside SQLite), so the ODBC -/// dispatch is paid three times, not once per row. Setup runs outside the +/// Setup goes through ODBC dispatch rather than reaching for the underlying +/// `rusqlite::Connection`, which core's `handles` module does not expose in any +/// case. Every setup here is three statements (`DROP`, `CREATE` and one bulk +/// `INSERT` whose rows are generated by a recursive CTE inside SQLite), so the +/// dispatch is paid three times, not once per row, and setup runs outside the /// measured section regardless. /// /// `SQLExecDirect` executes one statement, hence the split on `;`; none of the diff --git a/integration-tests/README.md b/integration-tests/README.md index d4dda87..bf3e95d 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -28,10 +28,10 @@ Both take `--help`. | `generated/` | Everything `setup.sh` writes. Gitignored | | `windows/` | The VM suite, its libvirt definitions, and [WINDOWS.md](windows/WINDOWS.md) | -`generated/` is ignored rather than committed because all three files it holds -name absolute paths: the driver's `.so`, the database. None of them survives -being moved to another checkout, so a committed copy would be wrong for -everyone but its author. +`generated/` is ignored rather than committed because the ODBC config it holds +names absolute paths. `odbcinst.ini` points at the driver's `.so` and +`odbc.ini` at the database, so neither survives being moved to another +checkout, and a committed copy would be wrong for everyone but its author. ## What gets run @@ -58,9 +58,10 @@ exactly that via the `cargo-test` hook. | `--skip-cargo-test` | Run the pyodbc suites only. What CI passes | | `--windows` | Additionally run the suite inside the Windows VM | -Any other argument is forwarded to `windows_test.py` (`--host`, `--gateway`, -`--user`, `--password`) and so is rejected without `--windows`: a flag -forwarded to a script that never runs is a flag silently ignored. +Any other argument is forwarded to `windows_test.py` (`--target`, `--host`, +`--vm-network`, `--user`, `--password`, `--gateway`) and so is rejected without +`--windows`, since a flag forwarded to a script that never runs would be +silently ignored. ## Windows @@ -74,11 +75,11 @@ See [windows/WINDOWS.md](windows/WINDOWS.md) for provisioning the VM. ## Interactively -`setup.sh` prints these at the end: +`setup.sh` prints these at the end, with absolute paths filled in: ```bash -export ODBCSYSINI=integration-tests/generated -export ODBCINI=integration-tests/generated/odbc.ini +export ODBCSYSINI=$(pwd)/integration-tests/generated +export ODBCINI=$(pwd)/integration-tests/generated/odbc.ini isql -3 test_sqlite -v ``` diff --git a/integration-tests/generated/.gitignore b/integration-tests/generated/.gitignore index eec6da5..df428c9 100644 --- a/integration-tests/generated/.gitignore +++ b/integration-tests/generated/.gitignore @@ -1,4 +1,4 @@ -# Everything setup.sh writes here embeds absolute paths, so none of it is +# The ODBC config setup.sh writes here embeds absolute paths, so none of it is # portable between checkouts. Keep the directory, ignore the contents. * !.gitignore diff --git a/integration-tests/scripts/lib.sh b/integration-tests/scripts/lib.sh index a0cd169..db5aa41 100644 --- a/integration-tests/scripts/lib.sh +++ b/integration-tests/scripts/lib.sh @@ -12,9 +12,9 @@ PROJECT_DIR="$(cd "$TEST_DIR/.." && pwd)" SUITES_DIR="$TEST_DIR/suites" WINDOWS_DIR="$TEST_DIR/windows" -# Everything setup.sh writes lands here, and the whole directory is gitignored: -# all three files embed absolute paths, so none of them is portable between -# checkouts. +# Everything setup.sh writes lands here, and the whole directory is gitignored. +# odbc.ini names the database and odbcinst.ini names the driver library, both by +# absolute path, so neither survives being moved to another checkout. GENERATED="$TEST_DIR/generated" DB_PATH="$GENERATED/test.db" ODBC_INI="$GENERATED/odbc.ini" diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh index f1675a2..4a06058 100755 --- a/integration-tests/scripts/run-tests.sh +++ b/integration-tests/scripts/run-tests.sh @@ -10,8 +10,9 @@ # ./integration-tests/run-tests.sh --skip-build # reuse the driver already built # ./integration-tests/run-tests.sh --skip-cargo-test # pyodbc only; what CI runs # -# Any other argument is forwarded to windows_test.py (--host, --gateway, --user, -# --password), and is therefore only accepted alongside --windows. +# Any other argument is forwarded to windows_test.py (--target, --host, +# --vm-network, --user, --password, --gateway), and is therefore only accepted +# alongside --windows. set -euo pipefail # shellcheck source=integration-tests/scripts/lib.sh diff --git a/integration-tests/windows/WINDOWS.md b/integration-tests/windows/WINDOWS.md index 5a035a6..c0e707a 100644 --- a/integration-tests/windows/WINDOWS.md +++ b/integration-tests/windows/WINDOWS.md @@ -1,8 +1,22 @@ -# Windows Testing +# Windows testing -## Quick start: running tests +`suites/test_integration.py`, driven through the Windows ODBC Driver Manager +over WinRM. The Windows DM is far stricter than unixODBC and tends to fail +silently, so this is measured rather than assumed. The target is a disposable +Windows Server VM on a host-only libvirt network, created by the Ansible +playbook in `vm/`. -Start the VM and its networks first (skip if already running): +The VM's credentials are `Administrator` / `Asdf1234`, the defaults in +`windows_test.py`. They are not a secret: the machine is local, throwaway, and +reachable only from the host that created it. Pass `--user` and `--password` +for a VM built some other way. + +## Quick start: running the tests + +If the VM does not exist yet, build it first: [Prerequisites](#prerequisites), +then [Creating the VM](#creating-the-vm). + +Start the VM and its networks (skip whatever is already running): ```bash virsh --connect qemu:///system net-start stackable-odbc-test-hostnet @@ -10,60 +24,82 @@ virsh --connect qemu:///system net-start stackable-odbc-test-internet virsh --connect qemu:///system start stackable-odbc-test ``` -Then run from the Linux host (`pywinrm` is installed automatically by `uv`). -This runs the full integration suite twice: DSN-less, then via DSN. +Then run from the Linux host. `uv` installs `pywinrm` itself: ```bash uv run --with pywinrm python3 integration-tests/windows/windows_test.py ``` -Common options: +The suite runs twice against the same database, DSN-less and then via a DSN, +exactly as it does on Linux. Nothing needs to be running on the host: SQLite is +compiled into the DLL, and the script copies a freshly built database to the VM. -```bash -# Skip the cargo build (use an already-built DLL) -uv run --with pywinrm python3 integration-tests/windows/windows_test.py --skip-build +**Do not diagnose a Windows failure without rebuilding the DLL first.** +`--skip-build` reuses whatever sits in `target/x86_64-pc-windows-gnu/release/`, +which can predate the feature under test by days. + +### Options + +`--help` lists them all. The ones that come up: -# Target a specific VM IP (skip DHCP lease discovery) -uv run --with pywinrm python3 integration-tests/windows/windows_test.py --host 192.168.197.138 +| Flag | Default | Effect | +|---|---|---| +| `--skip-build` | off | Use the DLL already in `target/`, rather than rebuilding. See the warning above | +| `--target {gnu,msvc}` | `gnu` | Which Windows target to build and deploy. `msvc` needs an MSVC-capable linker on the host; see [Building the DLL](#building-the-dll) | +| `--host <address>` | discovered from the libvirt DHCP leases | VM IP or hostname | +| `--vm-network <name>` | `stackable-odbc-test-hostnet` | The libvirt network that discovery reads leases from | +| `--user`, `--password` | `Administrator`, `Asdf1234` | WinRM credentials | +| `--gateway <ip>` | `$ODBC_TEST_HOST_GATEWAY`, else `192.168.197.1` | The host-only gateway address the VM reaches the host on, to download the DLL and the test files from a short-lived HTTP server | -# Non-default libvirt subnet -export ODBC_TEST_HOST_GATEWAY=10.0.0.1 -# or: --gateway 10.0.0.1 +### The setup dialog -# Full usage -uv run --with pywinrm python3 integration-tests/windows/windows_test.py --help +`windows_test.py` deploys `packaging/windows/configure-dsn.ps1` to +`C:\odbc_test\` beside the DLL, which is where the driver looks for it, and +registers the driver with `Setup=` pointing at the DLL. The suite itself +creates its DSN with `odbcconf`, which passes a null *hwndParent*, so it takes +the headless path and never displays anything. + +That means the dialog is deployed but not exercised automatically. To check it, +open the Administrator in the VM by hand: + +```cmd +%SystemRoot%\System32\odbcad32.exe ``` +**Add…** on `stackable_odbc_sqlite`, or **Configure…** on an existing data +source, should display the dialog, and its **Test connection** button should +report the SQLite version and a table count. The table count is the check worth +having, because SQLite creates a missing file rather than refusing, so a typo +in the path connects perfectly well and finds nothing. + ### Using a different hypervisor (VirtualBox, Hyper-V, etc.) The VM lifecycle section below uses QEMU/KVM via libvirt, and the test script -auto-discovers the VM IP from libvirt DHCP leases. If you are running Windows -in a different hypervisor, the test script still works; just pass the VM's IP -directly with `--host`: +auto-discovers the VM IP from libvirt DHCP leases. A Windows guest in another +hypervisor works too; pass its IP directly: ```bash uv run --with pywinrm python3 integration-tests/windows/windows_test.py --host <vm-ip> ``` -The VM must have WinRM enabled on port 5985 with NTLM auth, and Python 3 + -pyodbc installed. Override credentials with `--user` and `--password` if -they differ from the defaults. +The VM must have WinRM enabled on port 5985 with NTLM auth, and Python 3 plus +pyodbc installed. ### OpenSSL legacy provider WinRM uses NTLM authentication, which requires MD4, disabled by default in -modern OpenSSL. The test script automatically sets `OPENSSL_CONF` to point at +modern OpenSSL. The test script sets `OPENSSL_CONF` to point at `integration-tests/windows/openssl_legacy.cnf`, which enables the legacy provider. -If you see `unsupported hash type md4` errors, check that the file exists and -that you haven't overridden `OPENSSL_CONF` in your environment. +An `unsupported hash type md4` error means that file is missing, or that +`OPENSSL_CONF` is overridden in your environment. ## VM lifecycle ### Prerequisites -QEMU/KVM and libvirt must be installed and working as system services: +QEMU/KVM and libvirt must be installed and working as system services. `nix-shell` only provides Ansible and the Python bindings, not the virtualisation stack itself. Verify with: @@ -71,18 +107,16 @@ virtualisation stack itself. Verify with: virsh --connect qemu:///system list --all ``` -If this fails, install and configure QEMU/KVM + libvirt for your distro. -You will also need a `default` storage pool (`virsh pool-list`) and your -user must be in the `libvirt` group. +You also need a `default` storage pool (`virsh pool-list`), and your user must +be in the `libvirt` group. -**Note:** QEMU typically runs as a dedicated user (e.g. `libvirt-qemu`) -that cannot read files under your home directory. If the playbook fails -with a permission error on the ISO or virtio drivers, grant read access -with ACLs (e.g. `setfacl -m u:libvirt-qemu:r /path/to/file.iso` and +QEMU typically runs as a dedicated user (for example `libvirt-qemu`) that +cannot read files under your home directory. If the playbook fails with a +permission error on the ISO or the virtio drivers, grant read access with ACLs +(`setfacl -m u:libvirt-qemu:r /path/to/file.iso`, and `setfacl -m u:libvirt-qemu:x` on each parent directory). -For reference, on Ubuntu 24.04 the following was used to set up these -prerequisites (package names will differ on other distros): +On Ubuntu 24.04 the following was enough; package names differ elsewhere: ```bash sudo apt install -y qemu-system-x86 qemu-utils libvirt-daemon-system \ @@ -99,7 +133,6 @@ pipx install uv ```bash # Set once, pointing at your Windows Server 2022 evaluation ISO. -# Download from: https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022 export WINDOWS_ISO=~/Downloads/SERVER_EVAL_x64FRE_en-us.iso cd integration-tests/windows/vm @@ -107,17 +140,30 @@ nix-shell # loads Ansible + libvirt Python bindings ansible-playbook start.yaml -i inventory.ini ``` -The playbook creates a QEMU/KVM VM with two networks (host-only + -NAT), boots the Windows ISO, and waits for the guest agent. The -`Autounattend.xml` installs Python 3.12 and pyodbc automatically. +The playbook creates a QEMU/KVM VM with two networks (host-only and NAT), boots +the Windows ISO, and waits for the guest agent. `Autounattend.xml` installs +Python and pyodbc automatically. -First run takes ~30 minutes (Windows install + downloads). Use -`virt-viewer` or `virt-manager` to watch progress: +First run takes around 30 minutes, most of it the Windows install and the +downloads. Watch progress with: ```bash virt-viewer --connect qemu:///system stackable-odbc-test ``` +### What the current VM image was built with + +A snapshot of the image in use, not a set of requirements. Each pin and the +paths derived from it have to move together, which is why they are collected +here. + +| Thing | Value | Set in | +|---|---|---| +| Guest OS | Windows Server 2022 evaluation, [from the evalcenter](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022) | `$WINDOWS_ISO`, checked by `vm/start.yaml` | +| Guest Python | 3.12, at `C:\Program Files\Python312\python.exe` | `vm/files/windows-install-config/Autounattend.xml`, and `REMOTE_PYTHON` in `windows_test.py` | +| virtio-win drivers | 0.1.248 | `vm/start.yaml`, downloaded and checksummed | +| LLVM for the MSVC cross build | `llvmPackages_18` | the `nix-shell` line under [Building the DLL](#building-the-dll) | + ### Shutting down ```bash @@ -143,9 +189,12 @@ virsh --connect qemu:///system net-undefine stackable-odbc-test-internet ## Reference: driver and DSN management +Everything below is what `windows_test.py` does for you, written out for when +you are working in the VM by hand. + ### Building the DLL -The mingw cross-compiler is the simplest option (no extra tooling needed): +The mingw cross-compiler needs no extra tooling: ```bash cargo build --release --target x86_64-pc-windows-gnu @@ -153,8 +202,8 @@ cargo build --release --target x86_64-pc-windows-gnu Output: `target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll` -Alternatively, MSVC cross-compilation works via `cargo-xwin` (requires -`cargo install cargo-xwin` and nix for LLVM): +MSVC cross-compilation works through `cargo-xwin` (`cargo install cargo-xwin`, +plus nix for LLVM), and is what `--target msvc` builds: ```bash nix-shell -p llvmPackages_18.clang llvmPackages_18.lld llvmPackages_18.llvm --run \ @@ -163,8 +212,10 @@ nix-shell -p llvmPackages_18.clang llvmPackages_18.lld llvmPackages_18.llvm --ru Output: `target/x86_64-pc-windows-msvc/release/stackable_odbc_sqlite.dll` -Both produce DLLs that work with the Windows Driver Manager. Prefer mingw for -simplicity; use MSVC if you need to match the target environment exactly. +Both work with the Windows Driver Manager. Prefer mingw; use MSVC to match a +target environment exactly. Note that the harness builds with plain +`cargo build`, while release DLLs are built with `cargo auditable`, which +embeds the dependency list `packaging/sbom.sh` refuses an artifact without. ### Registering the driver @@ -172,7 +223,7 @@ All commands below run in `cmd.exe` as Administrator. Adjust the DLL path as needed. ```cmd -odbcconf.exe /A {INSTALLDRIVER "stackable_odbc_sqlite|Driver=C:\Users\Administrator\Downloads\stackable_odbc_sqlite.dll|Setup=C:\Users\Administrator\Downloads\stackable_odbc_sqlite.dll|"} +odbcconf.exe /A {INSTALLDRIVER "stackable_odbc_sqlite|Driver=C:\odbc_test\stackable_odbc_sqlite.dll|Setup=C:\odbc_test\stackable_odbc_sqlite.dll|"} ``` Both `Driver=` and `Setup=` must point to the same DLL, which exports both the @@ -180,38 +231,48 @@ ODBC API functions and the `ConfigDSNW` setup entry point. ### Creating a DSN -The driver's `ConfigDSNW` is headless (no GUI dialog), so DSNs must be created -programmatically rather than through the ODBC Data Source Administrator's "Add" -button: +Two ways. + +**The ODBC Data Source Administrator**, `odbcad32.exe` → **Add…**, which +displays the driver's dialog. `ConfigDSN` reaches +`SqliteBackend::configure_dsn`, which runs `configure-dsn.ps1` and hands the +keywords back for core to write. The script must sit beside the DLL. + +**`odbcconf`**, which is what the test harness uses. It passes a null +*hwndParent*, so no dialog is displayed and the keywords on the command line +are written as given: ```cmd -odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=MySQLite|Database=C:\odbc_test\test.db|"} +odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=test_sqlite|Database=C:\odbc_test\test.db|"} ``` ### Connection string parameters -| Parameter | Required | Description | -|-----------|----------|-------------| -| Database | Yes | Path to the SQLite database file (e.g. `C:\path\to\test.db`) | +`Database` is the only key. The full table is in the +[root README](../../README.md#connecting), and the authoritative list is +`src/backend/types/connect_params.rs`. ### Verifying registration Open `%SystemRoot%\System32\odbcad32.exe` (64-bit) and confirm: -- **Drivers tab**: `stackable_odbc_sqlite` is listed -- **User DSN tab**: `MySQLite` (or whatever DSN name you chose) is listed -- Selecting the driver under "Add" should produce no error (but also no dialog, which is expected for a headless driver) +- **Drivers tab**: `stackable_odbc_sqlite` is listed, with a version and + `Stackable GmbH` rather than `Not marked`. +- **User DSN tab**: `test_sqlite` (or whatever name you chose) is listed. +- **Add…** on the driver displays the setup dialog. See + [The setup dialog](#the-setup-dialog). ### Unregistering -Remove a DSN (User DSN entries are stored under `HKCU`): +Remove a DSN (User DSN entries live under `HKCU`): ```cmd -reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\MySQLite" /f -reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "MySQLite" /f +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\test_sqlite" /f +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "test_sqlite" /f ``` -Remove the driver (via registry, as `odbcconf` does not support `REMOVEDRIVER`): +Remove the driver (through the registry, since `odbcconf` has no +`REMOVEDRIVER`): ```cmd reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\stackable_odbc_sqlite" /f @@ -222,10 +283,9 @@ reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "stackable_odbc_sql ### PowerShell smoke test -PowerShell's `System.Data.Odbc` is built into .NET, so no extra tools are needed. -This example is self-contained: it creates its own table, queries it, and -cleans up. The driver must be registered first (done automatically by the -test script). +PowerShell's `System.Data.Odbc` is built into .NET, so no extra tools are +needed. This example is self-contained: it creates its own table, queries it, +and cleans up. The driver must be registered first, which the test script does. ```powershell $conn = New-Object System.Data.Odbc.OdbcConnection("Driver=stackable_odbc_sqlite;Database=C:\odbc_test\manual_test.db") @@ -264,25 +324,16 @@ Connected: Open Done ``` -**DSN-based connection:** - -The automated test script registers a DSN named `test_sqlite`. To use it -(in `cmd.exe`, not PowerShell): - -```cmd -odbcconf.exe /A {CONFIGDSN "stackable_odbc_sqlite" "DSN=MySQLite|Database=C:\odbc_test\manual_test.db|"} -``` - -Then in PowerShell: +To connect through the DSN the test script registers instead: ```powershell -$c = New-Object System.Data.Odbc.OdbcConnection("DSN=MySQLite"); $c.Open(); Write-Host "Connected: $($c.State)"; $c.Close() +$c = New-Object System.Data.Odbc.OdbcConnection("DSN=test_sqlite"); $c.Open(); Write-Host "Connected: $($c.State)"; $c.Close() ``` ### Running test_integration.py manually -If you need to run the tests without the wrapper script (e.g. from a -PowerShell session on the VM): +To run the suite without the wrapper script, from a PowerShell session on the +VM: ```powershell & "C:\Program Files\Python312\python.exe" C:\odbc_test\test_integration.py "Driver=stackable_odbc_sqlite;Database=C:\odbc_test\test.db" diff --git a/integration-tests/windows/windows_test.py b/integration-tests/windows/windows_test.py index 41def02..a6b2c36 100644 --- a/integration-tests/windows/windows_test.py +++ b/integration-tests/windows/windows_test.py @@ -47,7 +47,7 @@ # path because WinRM sessions may not have an up-to-date PATH. REMOTE_PYTHON = r'"C:\Program Files\Python312\python.exe"' -# The host-only network gateway (host side) — the VM can reach this IP to +# The host-only network gateway (host side). The VM reaches this IP to # download files from our HTTP server. Override with --gateway or # ODBC_TEST_HOST_GATEWAY for non-default libvirt subnets. DEFAULT_HOST_GATEWAY = "192.168.197.1" @@ -382,7 +382,7 @@ def register_driver(session): registered (it only increments UsageCount). Force-update via the registry to ensure the freshly deployed DLL is always used. """ - # Must use run_cmd (cmd.exe), not run_ps — PowerShell mangles odbcconf arguments. + # Must use run_cmd (cmd.exe), not run_ps: PowerShell mangles odbcconf arguments. cmd = ( f'odbcconf.exe /A {{INSTALLDRIVER ' f'"{DRIVER_NAME}|Driver={REMOTE_DLL}|Setup={REMOTE_DLL}|"}}' diff --git a/packaging/README.md b/packaging/README.md index 5276332..0a66b55 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -160,8 +160,38 @@ what SQLite does. A typo in the path therefore connects successfully and finds an empty database rather than failing, which is why the dialog's **Test connection** reports the table count. +## Support + +- [Issues](https://github.com/stackabletech/stackable-odbc-sqlite/issues) for + bugs +- [GitHub Discussions](https://github.com/orgs/stackabletech/discussions) for + questions +- [Discord](https://discord.gg/7kZ3BNnCAF) to talk to us + +A driver log is the most useful thing to attach to a report. Set two +environment variables before starting your application: + +```bash +export ODBC_LOG_LEVEL=debug # trace, debug, info, warn, error +export ODBC_LOG_FILE=/tmp/sqlite-odbc.log +``` + +On Windows, set the same two through **System Properties → Environment +Variables**. The log may contain your SQL, so check it before sharing. + +## The SBOM + +Each archive carries a CycloneDX software bill of materials next to the driver, +and the release page publishes an SPDX document for every artifact as well. +Both list what the binary actually links, the SQLite compiled inside it +included, so a security advisory can be checked against the driver you +installed rather than against whatever SQLite your system happens to have. + ## Building the archives from source +Everything below is for people building the driver themselves. If you +installed from a release archive, you are done. + From the **repository root**: ```bash @@ -182,12 +212,12 @@ VERSION=0.0.1 ./packaging/build-archives.sh That writes both archives, four SBOMs and `sha256sums.txt` to `packaging/dist/`. -### The SBOM +### How the SBOM is generated `packaging/sbom.sh` produces one CycloneDX and one SPDX document per artifact. The component list comes from the `.dep-v0` section `cargo auditable` embeds, -so it describes what was **linked** rather than what `Cargo.toml` asked for: -dev-dependencies are excluded by construction, and a git dependency's purl +so it describes what was **linked** rather than what `Cargo.toml` asked for. +Dev-dependencies are excluded by construction, and a git dependency's purl names the resolved commit rather than a branch that moves. Two kinds of component are invisible to cargo and are declared by hand in @@ -203,8 +233,4 @@ Two kinds of component are invisible to cargo and are declared by hand in statically. `./packaging/sbom.sh --check-native <artifact>` verifies both claims against the real binary, and CI runs it on every pull request. -`./packaging/test-sbom.sh` is the pipeline's own test suite. - -## Support - -<https://github.com/stackabletech/stackable-odbc-sqlite> +`./packaging/test-sbom.sh` is that pipeline's own test suite. diff --git a/packaging/sbom.sh b/packaging/sbom.sh index fe60a56..6b2045d 100755 --- a/packaging/sbom.sh +++ b/packaging/sbom.sh @@ -187,10 +187,10 @@ jq --slurpfile lut "$LOOKUP" ' # --- augment --------------------------------------------------------------- # Components the toolchain contributes are invisible to cargo. `common` holds -# the ones both artifacts carry -- SQLite itself, compiled in from the -# amalgamation -- and the platform key holds the rest: the ELF object links -# unixODBC at load time, while the Windows DLL imports only the operating -# system's own libraries and instead carries the mingw runtime statically. +# the ones both artifacts carry, meaning SQLite itself, compiled in from the +# amalgamation. The platform key holds the rest: the ELF object links unixODBC +# at load time, while the Windows DLL imports only the operating system's own +# libraries and instead carries the mingw runtime statically. case "$BASENAME" in *.so) NATIVE_KEY="linux" ;; *.dll) NATIVE_KEY="windows" ;; diff --git a/release.toml b/release.toml index 218fcc2..a720cd8 100644 --- a/release.toml +++ b/release.toml @@ -11,7 +11,7 @@ publish = false push = true # Signing is requested here rather than left to the releaser's `tag.gpgsign` / # `commit.gpgsign`, so a release tag is signed regardless of whose machine it -# is cut on — and fails loudly instead of silently producing an unsigned tag +# is cut on, and fails loudly instead of silently producing an unsigned tag # when no signing key is configured. sign-tag = true sign-commit = true @@ -54,7 +54,7 @@ replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-sqlite/ min = 0 # First-release case: the initial placeholder points at `commits/HEAD`. -# Fires exactly once — on the first release — after which the line is in the +# Fires exactly once, on the first release, after which the line is in the # `compare/...` form the rule above owns, and this one never matches again. [[pre-release-replacements]] file = "CHANGELOG.md" diff --git a/release/release.sh b/release/release.sh index ac9f3ef..35f6ea6 100755 --- a/release/release.sh +++ b/release/release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# release.sh — convenience wrapper around cargo-release. +# Convenience wrapper around cargo-release. # # Usage: # release/release.sh patch # dry-run a patch release @@ -8,8 +8,8 @@ # release/release.sh minor --execute # actually perform the release # # cargo-release is dry-run by default; --execute is required to mutate state. -# See release.toml for what a release rewrites (CHANGELOG.md, packaging/README.md) and -# for the `main`-only branch restriction. +# See release.toml for what a release rewrites (CHANGELOG.md and +# packaging/README.md) and for the `main`-only branch restriction. set -euo pipefail if [[ $# -lt 1 ]]; then diff --git a/src/backend.rs b/src/backend.rs index b5f497b..87e3e84 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,3 +1,9 @@ +//! Core type definitions for the SQLite backend ([`SqliteBackend`], +//! [`SqliteConnection`], [`SqliteStatement`]) plus `connect`, `disconnect`, +//! `end_tran`, error mapping, and the thin [`Backend`] delegation layer. +//! Statement execution, catalog metadata, `SQLGetInfo` and the DSN setup +//! dialog live in the submodules. + use std::{ borrow::Cow, collections::HashMap, @@ -670,10 +676,10 @@ impl Backend for SqliteBackend { /// connection"), and [`SqliteBackend::connect`] opens with a plain /// `rusqlite::Connection::open`, so it is unreachable. /// - /// Returning a single level also means core's default - /// [`Backend::set_txn_isolation`] is correct as-is: the one supported - /// level is always already in effect, and anything else is rejected with - /// `HY024` before it reaches the backend. + /// Returning a single level is also what lets core's default + /// [`Backend::set_txn_isolation`] stand: the one supported level is always + /// already in effect, and anything else is rejected with `HY024` before it + /// reaches the backend. fn txn_isolation_options(_conn: &SqliteConnection) -> u32 { SQL_TXN_SERIALIZABLE } @@ -772,8 +778,7 @@ impl Backend for SqliteBackend { /// No SQL-92 conformance level is claimed. /// - /// The previous `SQL_SC_SQL92_ENTRY` came from a core default, not from any - /// assessment of SQLite, and it contradicted this driver's own answers. The + /// `SQL_SC_SQL92_ENTRY` would contradict this driver's own answers. The /// spec ties entry level to three values: "a SQL-92 Entry level-conformant /// driver will always return the SQL_GB_GROUP_BY_EQUALS_SELECT option as /// supported", "will always return SQL_CN_ANY", and "will return @@ -782,9 +787,9 @@ impl Backend for SqliteBackend { /// (see [`SqliteBackend::group_by`]), which is a permissive extension, not /// entry-level behaviour. /// - /// `0` is the honest answer: it claims no level rather than asserting one + /// `0` is the honest answer, claiming no level rather than asserting one /// the driver demonstrably fails. Raising it later means auditing SQL-92 - /// entry level properly, not restoring the value core used to invent. + /// entry level properly. fn sql_conformance(_conn: &SqliteConnection) -> u32 { 0 } @@ -803,8 +808,8 @@ impl Backend for SqliteBackend { } /// See `info::SQLITE_SUBQUERIES`. Notably excludes `SQL_SQ_QUANTIFIED`, - /// which core's default claimed while this driver's - /// `SQL_SQL92_PREDICATES` denied it. + /// which must stay consistent with `SQL_SQL92_PREDICATES`, where + /// quantified comparison is likewise denied. fn subqueries(_conn: &SqliteConnection) -> u32 { info::SQLITE_SUBQUERIES } @@ -887,9 +892,8 @@ impl Backend for SqliteBackend { /// SQLite's tokenizer treats `$` as an identifier character, so /// `CREATE TABLE a$b (...)` parses and the name round-trips through /// `sqlite_master` unchanged. An application reads this info type to decide - /// when it must quote, so the previous `""` (core's old default, not a - /// claim this driver ever made) told it to quote a name that needs no - /// quoting. + /// when it must quote, so omitting `$` would make it quote a name that + /// needs no quoting. /// /// Every candidate is executed against the bundled library in /// `special_characters_are_each_live_probed`, which checks the characters diff --git a/src/backend/info.rs b/src/backend/info.rs index 0ab0ca0..2906999 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -405,11 +405,11 @@ fn sqlite_get_info( // a plain `rusqlite::Connection::open`, so shared cache is off and the // level is unreachable. // - // This previously advertised all four levels. Nothing applies the - // value an application sets (`SQL_ATTR_TXN_ISOLATION` is stored on - // the connection and read back, never pushed to SQLite), so an - // application that asked for REPEATABLE READ was told it had it while - // running serializable. + // Advertising all four would be a promise nothing keeps. Nothing + // applies the value an application sets (`SQL_ATTR_TXN_ISOLATION` is + // stored on the connection and read back, never pushed to SQLite), so + // an application asking for REPEATABLE READ would be told it had it + // while running serializable. // // Spec: <https://www.sqlite.org/isolation.html> InfoType::TransactionIsolationProtocol => { @@ -520,68 +520,14 @@ pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, Sql pub(crate) const SQLITE_AGGREGATE_FUNCTIONS: u32 = SQL_AF_AVG | SQL_AF_COUNT | SQL_AF_MAX | SQL_AF_MIN | SQL_AF_SUM | SQL_AF_DISTINCT | SQL_AF_ALL; -/// `SQL_ALTER_TABLE` (86): the `ALTER TABLE` clauses SQLite accepts, of those -/// the ODBC bitmap can express. -/// -/// Every bit here was established by executing the clause against the bundled -/// library (3.53.2), not read off the documentation. -/// `alter_table_capabilities_are_each_live_probed` is that probe, and it -/// checks the unclaimed bits too. That matters: `ADD CONSTRAINT` and -/// `DROP CONSTRAINT` are recent additions, rejected by 3.51.3 and accepted by -/// 3.53.2, so a bitmap written from an older recollection of SQLite's grammar -/// understates it. -/// -/// Claimed: -/// -/// - `ADD COLUMN`, with `DEFAULT` and `COLLATE`. -/// - `ADD CONSTRAINT <name> CHECK (...)`, which rewrites the stored schema to -/// carry a genuine table constraint. Note the ODBC bit is all-or-nothing -/// while SQLite accepts only `CHECK` here; `UNIQUE`, `PRIMARY KEY` and -/// `FOREIGN KEY` are still syntax errors. -/// - `SQL_AT_CONSTRAINT_NAME_DEFINITION`, since that `CONSTRAINT <name>` clause -/// is exactly what the bit describes. -/// -/// - `SQL_AT_ADD_CONSTRAINT`, which despite its name means "`ADD COLUMN` is -/// supported *with column constraints*", not table constraints. SQLite takes -/// `NOT NULL` (given a non-null default), `CHECK`, `REFERENCES` and a named -/// `CONSTRAINT` on an added column. Only `UNIQUE` and `PRIMARY KEY` are -/// refused, with "Cannot add a UNIQUE column". -/// -/// Supported by SQLite but *unrepresentable*, so absent by necessity rather -/// than because SQLite lacks them: unqualified `DROP COLUMN` (3.35.0+) and -/// unqualified `DROP CONSTRAINT`, for which the ODBC 3.x bitmap offers only -/// `CASCADE` and `RESTRICT` variants, and SQLite rejects both keywords, so -/// claiming either would advertise a syntax an application would send and have -/// refused. `sql.h` does carry ODBC 2.0-era `SQL_AT_ADD_COLUMN` and -/// `SQL_AT_DROP_COLUMN` bits for the unqualified forms, but the ODBC 3.x -/// `SQL_ALTER_TABLE` table does not define them, and this driver reports -/// `SQL_OIC_CORE` against ODBC 3.x. `RENAME TO` and `RENAME COLUMN` have no -/// bit at all. -/// -/// Deliberately **not** claimed: the four `SQL_AT_CONSTRAINT_*` deferrability -/// bits. SQLite implements deferred constraints only inside a foreign-key -/// clause, and its parser additionally accepts `DEFERRABLE` after a `CHECK` or -/// `NOT NULL` constraint, where SQL-92 does not allow it and where it has no -/// effect. Accepting a token is not implementing the attribute, and deriving a -/// general capability from an FK-only feature plus a permissive parser is -/// exactly the overstatement these bitmaps invite. -/// -/// Genuinely absent: `ALTER COLUMN ... SET DEFAULT` and -/// `ALTER COLUMN ... DROP DEFAULT` are not SQLite grammar. -/// -/// Core previously defaulted this to 0, which said SQLite cannot alter a table -/// in any way. -/// -/// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> -/// SQLite: <https://www.sqlite.org/lang_altertable.html> /// `SQL_SUBQUERIES` (95): the subquery forms SQLite accepts. /// /// `SQL_SQ_QUANTIFIED` is deliberately absent. It covers `< ALL` / `< ANY` / /// `< SOME`, which SQLite does not parse. That is the same finding /// `sql92_predicates_excludes_quantified_comparison_and_match` records for -/// `SQL_SP_QUANTIFIED_COMPARISON`. Core's default claimed it, so this driver -/// denied quantified comparison in one info type and asserted it in another. -/// Each remaining bit is exercised by `subqueries_are_each_live_probed`. +/// `SQL_SP_QUANTIFIED_COMPARISON`. Claiming it here would deny quantified +/// comparison in one info type while asserting it in another. Each remaining +/// bit is exercised by `subqueries_are_each_live_probed`. pub(crate) const SQLITE_SUBQUERIES: u32 = SQL_SQ_COMPARISON | SQL_SQ_EXISTS | SQL_SQ_IN | SQL_SQ_CORRELATED_SUBQUERIES; @@ -628,6 +574,56 @@ pub(crate) const SQLITE_OUTER_JOIN_CAPABILITIES: u32 = SQL_OJ_LEFT | SQL_OJ_INNER | SQL_OJ_ALL_COMPARISON_OPS; +/// `SQL_ALTER_TABLE` (86): the `ALTER TABLE` clauses SQLite accepts, of those +/// the ODBC bitmap can express. +/// +/// Every bit here was established by executing the clause against the bundled +/// library (3.53.2), not read off the documentation. +/// `alter_table_capabilities_are_each_live_probed` is that probe, and it +/// checks the unclaimed bits too. That matters: `ADD CONSTRAINT` and +/// `DROP CONSTRAINT` are recent additions, rejected by 3.51.3 and accepted by +/// 3.53.2, so a bitmap written from an older recollection of SQLite's grammar +/// understates it. +/// +/// Claimed: +/// +/// - `ADD COLUMN`, with `DEFAULT` and `COLLATE`. +/// - `ADD CONSTRAINT <name> CHECK (...)`, which rewrites the stored schema to +/// carry a genuine table constraint. Note the ODBC bit is all-or-nothing +/// while SQLite accepts only `CHECK` here; `UNIQUE`, `PRIMARY KEY` and +/// `FOREIGN KEY` are still syntax errors. +/// - `SQL_AT_CONSTRAINT_NAME_DEFINITION`, since that `CONSTRAINT <name>` clause +/// is exactly what the bit describes. +/// - `SQL_AT_ADD_CONSTRAINT`, which despite its name means "`ADD COLUMN` is +/// supported *with column constraints*", not table constraints. SQLite takes +/// `NOT NULL` (given a non-null default), `CHECK`, `REFERENCES` and a named +/// `CONSTRAINT` on an added column. Only `UNIQUE` and `PRIMARY KEY` are +/// refused, with "Cannot add a UNIQUE column". +/// +/// Supported by SQLite but *unrepresentable*, so absent by necessity rather +/// than because SQLite lacks them: unqualified `DROP COLUMN` (3.35.0+) and +/// unqualified `DROP CONSTRAINT`, for which the ODBC 3.x bitmap offers only +/// `CASCADE` and `RESTRICT` variants, and SQLite rejects both keywords, so +/// claiming either would advertise a syntax an application would send and have +/// refused. `sql.h` does carry ODBC 2.0-era `SQL_AT_ADD_COLUMN` and +/// `SQL_AT_DROP_COLUMN` bits for the unqualified forms, but the ODBC 3.x +/// `SQL_ALTER_TABLE` table does not define them, and this driver reports +/// `SQL_OIC_CORE` against ODBC 3.x. `RENAME TO` and `RENAME COLUMN` have no +/// bit at all. +/// +/// Deliberately **not** claimed: the four `SQL_AT_CONSTRAINT_*` deferrability +/// bits. SQLite implements deferred constraints only inside a foreign-key +/// clause, and its parser additionally accepts `DEFERRABLE` after a `CHECK` or +/// `NOT NULL` constraint, where SQL-92 does not allow it and where it has no +/// effect. Accepting a token is not implementing the attribute, and deriving a +/// general capability from an FK-only feature plus a permissive parser is +/// exactly the overstatement these bitmaps invite. +/// +/// Genuinely absent: `ALTER COLUMN ... SET DEFAULT` and +/// `ALTER COLUMN ... DROP DEFAULT` are not SQLite grammar. +/// +/// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> +/// SQLite: <https://www.sqlite.org/lang_altertable.html> pub(crate) const SQLITE_ALTER_TABLE: u32 = SQL_AT_ADD_COLUMN_SINGLE | SQL_AT_ADD_COLUMN_DEFAULT | SQL_AT_ADD_COLUMN_COLLATION @@ -1117,9 +1113,9 @@ mod tests { // because the snapshot's job is the value an application sees // regardless of which layer produced it. (InfoType::CursorSensitivity, Expected::U32(SQL_UNSPECIFIED as u32)), - // SQL_SQ_QUANTIFIED dropped: `< ALL` / `< ANY` / `< SOME` do not - // parse, which SQL_SQL92_PREDICATES already recorded. Core's default - // claimed it, so the two info types disagreed. + // SQL_SQ_QUANTIFIED is absent: `< ALL` / `< ANY` / `< SOME` do not + // parse, which SQL_SQL92_PREDICATES already records. Claiming it here + // would make the two info types disagree. (InfoType::Subqueries, Expected::U32(SQLITE_SUBQUERIES)), (InfoType::UnionStatement, Expected::U32(SQLITE_UNION)), (InfoType::DefaultTxnIsolation, Expected::U32(SQL_TXN_SERIALIZABLE)), @@ -1133,8 +1129,8 @@ mod tests { (InfoType::MaxIndexSize, Expected::U32(0)), (InfoType::MaxRowSize, Expected::U32(0)), (InfoType::MaxStatementLen, Expected::U32(0)), - // Not 0: SQLite implements every outer-join form the spec asks - // about. Core's default of 0 contradicted SQL_OUTER_JOINS = "Y". + // Not 0: SQLite implements every outer-join form the spec asks about, + // and 0 would contradict SQL_OUTER_JOINS = "Y". (InfoType::OuterJoinCapabilities, Expected::U32( SQL_OJ_LEFT | SQL_OJ_RIGHT | SQL_OJ_FULL | SQL_OJ_NESTED | SQL_OJ_NOT_ORDERED | SQL_OJ_INNER | SQL_OJ_ALL_COMPARISON_OPS)), @@ -1528,11 +1524,11 @@ mod tests { /// subquery form it describes, and the one it does not claim, proved by /// the bundled library rejecting it. /// - /// `SQL_SQ_QUANTIFIED` is the point. Core's default claimed it while this - /// driver's `SQL_SQL92_PREDICATES` denied `SQL_SP_QUANTIFIED_COMPARISON`, - /// so the same capability was advertised and denied by two info types. A - /// BI tool reading `SQL_SUBQUERIES` would push down `< ALL` and get a - /// syntax error. + /// `SQL_SQ_QUANTIFIED` is the point. Claiming it while + /// `SQL_SQL92_PREDICATES` denies `SQL_SP_QUANTIFIED_COMPARISON` would + /// advertise and deny the same capability across two info types, and a BI + /// tool reading `SQL_SUBQUERIES` would push down `< ALL` and get a syntax + /// error. #[test] fn subqueries_are_each_live_probed() { let conn = rusqlite::Connection::open_in_memory().unwrap(); @@ -1672,11 +1668,10 @@ mod tests { } /// The five catalog info types and the two schema info types must agree - /// with each other. This is the test the previous arrangement lacked: - /// `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` said - /// catalogs do not exist while `SQL_CATALOG_TERM` and - /// `SQL_CATALOG_NAME_SEPARATOR` fell through to core's defaults and named - /// one, and nothing tied the two groups together. + /// with each other. Without this test nothing ties the two groups + /// together, and `SQL_CATALOG_NAME`, `SQL_CATALOG_LOCATION` and + /// `SQL_CATALOG_USAGE` can say catalogs do not exist while + /// `SQL_CATALOG_TERM` and `SQL_CATALOG_NAME_SEPARATOR` name one. /// /// Asserts the spec's rule, not the current values, so it keeps holding if /// [`SqliteBackend::supports_catalogs`] or @@ -1749,7 +1744,7 @@ mod tests { /// SQLite is serializable and has no way to be anything else here: READ /// COMMITTED and REPEATABLE READ are not SQLite concepts, and READ /// UNCOMMITTED needs shared-cache mode, which `SqliteBackend::connect` - /// never enables. The bitmap previously advertised all four. + /// never enables. /// /// This matters more than an unused info value usually would, because /// nothing applies what an application sets: `SQL_ATTR_TXN_ISOLATION` is @@ -1907,9 +1902,8 @@ mod tests { /// ever took the bundled library below that, this fails with a parse error /// instead of the bitmap overclaiming forever. /// - /// Core's default for `SQL_OUTER_JOIN_CAPABILITIES` is 0, which said - /// SQLite supports no outer joins at all while this driver's own - /// `SQL_OUTER_JOINS` said "Y". + /// A `SQL_OUTER_JOIN_CAPABILITIES` of 0 would say SQLite supports no outer + /// joins at all, while this driver's own `SQL_OUTER_JOINS` says "Y". #[test] fn outer_join_capabilities_are_each_live_probed() { let conn = rusqlite::Connection::open_in_memory().unwrap(); diff --git a/src/backend/metadata.rs b/src/backend/metadata.rs index fce91e5..3501732 100644 --- a/src/backend/metadata.rs +++ b/src/backend/metadata.rs @@ -245,8 +245,8 @@ const TABLE_TYPE_VIEW: &str = "VIEW"; /// Rows for `SQLTables`. /// /// The `SQL_ALL_CATALOGS` / `SQL_ALL_SCHEMAS` / `SQL_ALL_TABLE_TYPES` -/// enumerations no longer reach here: core detects them from the raw arguments -/// and answers them from `supports_catalogs`, `supports_schemas` and +/// enumerations never reach here. Core detects them from the raw arguments and +/// answers them from `supports_catalogs`, `supports_schemas` and /// [`table_types`]. Rows are returned unsorted; core orders them by /// TABLE_TYPE, TABLE_CAT, TABLE_SCHEM, TABLE_NAME. pub(super) fn tables( @@ -1078,8 +1078,8 @@ mod tests { #[test] fn columns_column_name_is_a_like_pattern() { let conn = setup_test_db(); - // types_test columns: id, val, label. "%l%" matches val and label. - // Under the old exact-match filter this returned zero rows. + // types_test columns: id, val, label. "%l%" matches val and label, + // which an exact-match filter would miss entirely. let rows = columns( &conn, &ColumnsQuery::default() @@ -1209,10 +1209,9 @@ mod tests { /// `PKCOLUMN_NAME` is one of the columns the spec marks "not NULL", and /// `ForeignKeyRow` enforces that. `REFERENCES parent` with no column list - /// leaves `PRAGMA foreign_key_list`'s `to` NULL, which this driver used to - /// report as a NULL `PKCOLUMN_NAME`, a value the column cannot hold. - /// SQLite defines the implicit target as the parent's primary key, so the - /// name is recovered rather than dropped. + /// leaves `PRAGMA foreign_key_list`'s `to` NULL, which is not a value the + /// column can hold. SQLite defines the implicit target as the parent's + /// primary key, so the name is recovered rather than dropped. #[test] fn foreign_keys_implicit_reference_resolves_the_parent_primary_key() { let conn = rusqlite::Connection::open_in_memory().unwrap(); diff --git a/src/backend/setup.rs b/src/backend/setup.rs index ef47ec0..c6e33f5 100644 --- a/src/backend/setup.rs +++ b/src/backend/setup.rs @@ -58,7 +58,7 @@ const EXIT_CANCELLED: i32 = 2; /// owns, and removing the pointer does not remove the file. /// /// `Add` and `Config` prompt. Everything else passes the attributes through -/// unchanged, which is exactly core's defaulted behaviour. +/// unchanged, which is what core does on its own. fn dialog_needed(hwnd_is_null: bool, request: ConfigRequest) -> bool { if hwnd_is_null { return false; diff --git a/src/escape_dialect.rs b/src/escape_dialect.rs index 263d0b4..e5d9f90 100644 --- a/src/escape_dialect.rs +++ b/src/escape_dialect.rs @@ -5,51 +5,13 @@ //! the bundled 3.53.2 build spells differently from ODBC. //! //! The remap table is traceable to the `SQL_*_FUNCTIONS` bitmaps -//! `src/backend/info.rs` advertises for SQLite. -//! Every arm below corresponds to one advertised `SQL_FN_*` -//! bit whose ODBC name SQLite spells differently *and* for which a bare name -//! substitution (`stackable_odbc_core::escape` only ever swaps the identifier in front -//! of the parentheses, it does not rewrite argument syntax or values) still -//! produces valid, semantically equivalent SQLite SQL. -//! -//! - `SQL_FN_STR_UCASE` / `SQL_FN_STR_LCASE`: SQLite's `upper()` / `lower()`. -//! - `SQL_FN_STR_SUBSTRING`: SQLite's `substr(string, start, length)` takes -//! the same argument order and 1-based indexing as ODBC's `SUBSTRING`, so -//! a bare name swap is exact. -//! - `SQL_FN_STR_ASCII`: SQLite's `unicode(x)` returns the code point of the -//! first character of `x`, the same one-argument shape as ODBC's `ASCII`. -//! - `SQL_FN_TD_NOW` / `SQL_FN_TD_CURDATE` / `SQL_FN_TD_CURTIME`: SQLite's -//! `datetime()` / `date()` / `time()` take no arguments and return the -//! current value (see the `SQL_TIMEDATE_FUNCTIONS` doc comment in -//! `backend/info.rs`). They are real callable functions, so `{fn NOW()}` / -//! `{fn CURDATE()}` / `{fn CURTIME()}`'s trailing `()` remains valid SQLite -//! syntax after the name swap. -//! -//! Advertised names that are NOT remapped here, and why: -//! -//! - `SQL_FN_STR_CONCAT`, `LTRIM`, `LENGTH`, `REPLACE`, `RTRIM`, `CHAR`, -//! `SOUNDEX`, `OCTET_LENGTH`; `SQL_FN_NUM_ABS`, `SIGN`, `ROUND`; -//! `SQL_FN_SYS_IFNULL`: SQLite spells every one of these identically to -//! ODBC (case-insensitively): `concat()`, `ltrim()`, `length()`, -//! `replace()`, `rtrim()`, `char()`, `soundex()`, `octet_length()`, -//! `abs()`, `sign()`, `round()`, `ifnull()`, so they pass through -//! unchanged (`None`). SQLite has `ifnull()` natively, so no substitution -//! is needed for `SQL_FN_SYS_IFNULL`. -//! -//! Names handled by [`rewrite_scalar_fn`] rather than the remap table: -//! -//! - `SQL_FN_TD_CURRENT_DATE` / `SQL_FN_TD_CURRENT_TIME` / -//! `SQL_FN_TD_CURRENT_TIMESTAMP`: SQLite's `CURRENT_DATE` / `CURRENT_TIME` -//! / `CURRENT_TIMESTAMP` are bare keywords, not callable functions. -//! `SELECT CURRENT_DATE();` is a syntax error (confirmed live: "near '(': -//! syntax error"). The ODBC escape always includes `()` (e.g. -//! `{fn CURRENT_DATE()}`), and a name-only rename appends whatever follows -//! the name verbatim, so it cannot drop that trailing `()`. -//! -//! These three were advertised in `SQL_TIMEDATE_FUNCTIONS` while no -//! translation existed for them, so `{fn CURRENT_DATE()}` reached SQLite as -//! `CURRENT_DATE()` and failed to prepare. `rewrite_scalar_fn` replaces the -//! whole escape, which is what emitting a bare keyword requires. +//! `src/backend/info.rs` advertises. A name belongs in [`remap_scalar_fn`] +//! only if swapping the identifier alone still yields valid, semantically +//! equivalent SQLite, because `stackable_odbc_core::escape` replaces the name +//! in front of the parentheses and rewrites neither argument syntax nor +//! values. Names SQLite spells the same way as ODBC pass through untouched. +//! The three bare-keyword date/time forms need a whole-escape rewrite instead; +//! see [`rewrite_scalar_fn`]. use stackable_odbc_core::escape::EscapeDialect; /// Remap an ODBC `{fn NAME(...)}` scalar-function name to SQLite's spelling. diff --git a/src/lib.rs b/src/lib.rs index 250b5fe..5547e64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -103,8 +103,8 @@ mod packaging_tests { /// linked. /// /// SQLite is compiled into the driver from the amalgamation - /// `libsqlite3-sys` vendors, so cargo — and therefore `cargo auditable`, - /// syft and the SBOM — sees only the wrapper crate. The C library inside + /// `libsqlite3-sys` vendors, so cargo (and therefore `cargo auditable`, + /// syft and the SBOM) sees only the wrapper crate. The C library inside /// it is the component an advisory against SQLite would name, and the only /// place its version is written down is that fragment. A `libsqlite3-sys` /// bump changes the bundled version with nothing else to notice. diff --git a/src/type_conversion.rs b/src/type_conversion.rs index d25def4..c834c4d 100644 --- a/src/type_conversion.rs +++ b/src/type_conversion.rs @@ -58,8 +58,8 @@ pub(crate) const MAX_FRACTIONAL_SECONDS_PRECISION: i16 = 3; // must not carry this backend-specific knowledge (see its `write_column_value` // doc comment). -/// Convert a [`ColumnValue`] (from ODBC parameter binding) to a [`rusqlite::types::Value`] -/// so it can be passed to `params_from_iter` in parameterized queries. +/// Convert a bound ODBC parameter into the [`rusqlite::types::Value`] +/// `params_from_iter` takes. /// /// Date/Time/Timestamp values are formatted as ISO-8601 strings, which SQLite /// stores and compares correctly via its built-in date functions. From 30f75221a56b560468138707dff33f51e864d63c Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 16:32:15 +0200 Subject: [PATCH 33/50] fix: report SQL_TC_ALL for SQL_TXN_CAPABLE, and probe it The driver reported `SQL_TC_DML`, which the spec defines as "Transactions support only Data Manipulation Language (DML) statements [...] Data Definition Language (DDL) statements encountered in a transaction cause an error." SQLite does the opposite. Measured against the bundled 3.53.2, a `CREATE TABLE` between two inserts inside a transaction raises nothing, and a later `ROLLBACK` undoes the table along with the rows. That is `SQL_TC_ALL`, "Transactions support both DML and DDL statements in any order". The hook's own doc comment already described the correct behaviour and then picked the contradicting value, on the grounds that `SQL_TC_DML` was the weaker, safer claim. It is not a weaker claim, it is the opposite one: an application reading it before running DDL inside a transaction either refuses, or commits first and silently discards the atomicity the user asked for. `transaction_capability_is_live_probed` now measures it, and separates all four non-`NONE` values in one run: no error rules out `SQL_TC_DML`, the surrounding inserts disappearing rules out `SQL_TC_DDL_COMMIT`, and the created table disappearing rules out `SQL_TC_DDL_IGNORE`. Each assertion names the value it eliminates, so a future SQLite change points at the right answer. No changelog entry: nothing has been released yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 16 ++++++++-- src/backend.rs | 36 +++++++++++++--------- src/backend/info.rs | 58 ++++++++++++++++++++++++++++++++++-- src/ffi_integration_tests.rs | 2 +- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c9c79c..fdb2ba0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -286,7 +286,7 @@ describe the same thing and must be changed together: | `SQL_OUTER_JOINS` | `SQL_OUTER_JOIN_CAPABILITIES` | | `SQL_SQL_CONFORMANCE` | `SQL_GROUP_BY`, `SQL_CONCAT_NULL_BEHAVIOR`, `SQL_NON_NULLABLE_COLUMNS` | | `SQL_SQL92_PREDICATES` (`SQL_SP_QUANTIFIED_COMPARISON`) | `SQL_SUBQUERIES` (`SQL_SQ_QUANTIFIED`) | -| `SQL_TXN_ISOLATION_OPTION` | whatever actually applies the level an application sets | +| `SQL_TXN_ISOLATION_OPTION` | `SQL_TXN_CAPABLE`, and whatever actually applies the level an application sets | When adding or changing a capability, look for the other info type that talks about the same thing, and assert the relationship. @@ -304,12 +304,24 @@ would depend on a dependency's build flags rather than on this driver. `integrity_enhancement_facility_is_actually_enforced` checks it through `connect`. -SQLite supports transactions and this driver reports `SQL_TC_DML` for +SQLite supports transactions and this driver reports `SQL_TC_ALL` for `SQL_TXN_CAPABLE`, so manual-commit mode is honoured for real: `set_autocommit(false)` issues `BEGIN`, and `end_tran` issues `COMMIT` or `ROLLBACK` and then opens the next transaction while still in manual-commit mode. +`SQL_TC_ALL` is the measured answer, not the optimistic one. The four +non-`NONE` values differ only in what DDL does inside a transaction, and the +spec separates them by observable effect: `SQL_TC_DML` means DDL "cause[s] an +error", `SQL_TC_DDL_COMMIT` that it commits, `SQL_TC_DDL_IGNORE` that it is +ignored. SQLite's DDL is transactional, so a `CREATE TABLE` between two +inserts raises nothing and a later `ROLLBACK` undoes the table along with the +rows. `transaction_capability_is_live_probed` runs exactly that and rules out +all three alternatives at once. Note that `SQL_TC_DML` is not a cautious +weaker claim: it asserts that DDL errors, so reporting it would make an +application either refuse DDL inside a transaction or commit before sending +it, silently dropping the atomicity the user asked for. + Both `cursor_commit_behavior` and `cursor_rollback_behavior` return `CursorBehavior::Preserve`, and **this depends on an implementation detail**: `execute::exec_direct` materialises every result set eagerly, so no diff --git a/src/backend.rs b/src/backend.rs index 87e3e84..784de66 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -18,7 +18,7 @@ use stackable_odbc_core::{ types::{ ColumnDescriptor, ColumnRow, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, ForeignKeyRow, InfoValue, PrimaryKeyRow, SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, - SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TC_DML, SQL_TXN_SERIALIZABLE, + SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TC_ALL, SQL_TXN_SERIALIZABLE, SpecialColumnRow, StatisticsRow, TableRow, TypeInfoRow, }, }; @@ -501,7 +501,7 @@ impl Backend for SqliteBackend { Cow::Borrowed(&[Cow::Borrowed("database")]) } - /// SQLite supports transactions and this driver reports `SQL_TC_DML` for + /// SQLite supports transactions and this driver reports `SQL_TC_ALL` for /// `SQL_TXN_CAPABLE`, so manual-commit mode must actually be honoured. /// /// Manual-commit mode is entered by opening a transaction with `BEGIN`; @@ -684,28 +684,36 @@ impl Backend for SqliteBackend { SQL_TXN_SERIALIZABLE } - /// `SQL_TC_DML`: SQLite runs DML inside a transaction, and a DDL statement - /// inside one causes neither a commit nor an error. SQLite's DDL is - /// transactional, so `CREATE TABLE` simply participates. + /// `SQL_TC_ALL`: "Transactions support both DML and DDL statements in any + /// order", which is what SQLite does. Its DDL is transactional, so a + /// `CREATE TABLE` inside a transaction simply participates, and a later + /// `ROLLBACK` undoes the table along with the rows. /// - /// `SQL_TC_ALL` would be the stronger claim and is tempting for that - /// reason, but the spec defines it as "transactions can contain DDL - /// statements **and** DML statements in any order", and this driver's - /// manual-commit mode is built on `BEGIN`/`COMMIT` around whatever the - /// application sends. `SQL_TC_DML` states what an application can rely on - /// without also promising the DDL-ordering freedom the spec attaches to - /// `SQL_TC_ALL`. + /// `SQL_TC_DML` is the tempting-looking weaker answer and is wrong. The + /// spec defines it as "DDL statements encountered in a transaction cause + /// an error", so it is not a smaller promise but the opposite claim. An + /// application reading it before running DDL inside a transaction would + /// either refuse, or commit first and silently discard the atomicity the + /// user asked for. + /// + /// `transaction_capability_is_live_probed` measures all of it against the + /// bundled library: that the DDL raises no error, that a rollback undoes + /// the rows around it, and that it undoes the schema change too, which is + /// what rules out `SQL_TC_DDL_COMMIT` and `SQL_TC_DDL_IGNORE`. /// /// Core pins this against [`SqliteBackend::txn_isolation_options`]: /// `SQL_TC_NONE` if and only if no isolation level is declared. Declaring a /// level and then reporting no transaction support is the /// self-contradiction that pairing exists to catch. /// - /// `SQL_TC_DML` is a small fixed constant, so the narrowing `as u16` + /// `SQL_TC_ALL` is a small fixed constant, so the narrowing `as u16` /// cannot lose information. (The `SQL_TC_*` constants are typed `u32` for /// bitmask use, while the info type is `SQLUSMALLINT`.) + /// + /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function> + /// SQLite: <https://www.sqlite.org/lang_transaction.html> fn txn_capable(_conn: &SqliteConnection) -> u16 { - SQL_TC_DML as u16 + SQL_TC_ALL as u16 } /// `true`: each connection this driver opens is its own diff --git a/src/backend/info.rs b/src/backend/info.rs index 2906999..55ca62d 100644 --- a/src/backend/info.rs +++ b/src/backend/info.rs @@ -1009,7 +1009,7 @@ mod tests { SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, SQL_SP_MATCH_UNIQUE_PARTIAL, SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, - SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_DML, + SQL_SQ_QUANTIFIED, SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_UNION_JOIN, SQL_TC_ALL, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SQL_UNSPECIFIED, }; @@ -1101,7 +1101,7 @@ mod tests { (InfoType::CatalogLocation, Expected::U16(0)), // TransactionCapable is SQLUSMALLINT per spec, not SQLUINTEGER. See // the matching comment on its arm in sqlite_get_info. - (InfoType::TransactionCapable, Expected::U16(SQL_TC_DML as u16)), + (InfoType::TransactionCapable, Expected::U16(SQL_TC_ALL as u16)), // --- U32 values --- // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT. See // the matching comment in stackable-odbc-core's default_get_info. @@ -1737,6 +1737,60 @@ mod tests { } } + /// `SQL_TXN_CAPABLE` is measured rather than assumed, because the four + /// non-`NONE` values differ only in what DDL does inside a transaction and + /// nothing about the constant's name says which one SQLite is. + /// + /// The spec separates them by observable effect: `SQL_TC_DML` means DDL + /// "cause[s] an error", `SQL_TC_DDL_COMMIT` that it commits the + /// transaction, `SQL_TC_DDL_IGNORE` that it is ignored, and `SQL_TC_ALL` + /// that DML and DDL are supported "in any order". So the probe runs a + /// `CREATE TABLE` between two inserts and rolls back, which tells all four + /// apart at once: no error rules out `SQL_TC_DML`, the inserts + /// disappearing rules out `SQL_TC_DDL_COMMIT`, and the created table + /// disappearing rules out `SQL_TC_DDL_IGNORE`. + #[test] + fn transaction_capability_is_live_probed() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch("CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (1);") + .unwrap(); + + conn.execute_batch("BEGIN").unwrap(); + conn.execute_batch("INSERT INTO t VALUES (2)").unwrap(); + conn.execute_batch("CREATE TABLE mid (x TEXT)") + .expect("DDL inside a transaction must not error, which is what SQL_TC_DML claims"); + conn.execute_batch("INSERT INTO t VALUES (3)").unwrap(); + conn.execute_batch("ROLLBACK").unwrap(); + + let rows: i64 = conn + .query_row("SELECT count(*) FROM t", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + rows, 1, + "the rows around the DDL survived a ROLLBACK, so the DDL committed \ + the transaction and this is SQL_TC_DDL_COMMIT" + ); + + let mid: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE name = 'mid'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + mid, 0, + "the table created inside the transaction survived a ROLLBACK, so the \ + DDL was not transactional and this is SQL_TC_DDL_IGNORE" + ); + + let reported = SqliteBackend::txn_capable(&test_connection()); + assert_eq!( + reported, SQL_TC_ALL as u16, + "SQLite runs DDL and DML in a transaction in any order, which is SQL_TC_ALL" + ); + } + /// `SQL_DEFAULT_TXN_ISOLATION` must name a level that /// `SQL_TXN_ISOLATION_OPTION` actually offers, and this driver offers /// exactly one. diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 3c470e6..0f3a815 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -3705,7 +3705,7 @@ fn autocommit_off_then_rollback_discards_changes() { 0, ), SqlReturn::SUCCESS, - "SQLite advertises SQL_TC_DML so manual-commit must be accepted" + "SQLite advertises SQL_TC_ALL so manual-commit must be accepted" ); assert_eq!( From 2948c8836da097da462b7a89fd9d572a5da9c635 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 16:58:51 +0200 Subject: [PATCH 34/50] feat: enforce SQL_ATTR_QUERY_TIMEOUT, and observe cancellation `set_query_timeout` fell through to core's default, so a requested timeout was substituted with `0` and reported as `01S02`: an application asking for thirty seconds was told, correctly but unhelpfully, that it had no deadline at all. SQLite has no server-side statement deadline to set, so `QueryTimeout:: DataSource` is unavailable. `QueryTimeout::CoreCancels` is the honest answer and its precondition already held: `cancel` really cancels, via `sqlite3_interrupt`. Core now arms its own timer and calls it when the deadline passes. The deadline covers execution rather than fetching, which is where the time goes, because `exec_direct` materialises every row before returning. `CoreCancels` asks for `is_cancelled` alongside, which needed a cancel token that can be observed. `Backend::CancelToken` becomes `SqliteCancelToken`, pairing the connection's interrupt handle with a flag of its own. The flag is minted fresh per `cancel_token` call rather than shared with the connection: core mints a token per statement-producing call, and a shared flag would leave a cancelled statement permanently unusable, where the spec says "After the statement has been canceled, the application can call SQLExecute or SQLExecDirect again." `query_timeout_stops_a_long_running_statement` runs a recursive CTE past a one-second deadline through the real entry points. Verified by mutation in both directions, which corrected an assumption worth recording: `HYT00` does *not* come from `is_cancelled`. Core marks its own `CancelState` timed out before cancelling and relabels the failure ahead of the `HY008` reclassification, so stubbing `is_cancelled` to `false` leaves the test passing while reverting `set_query_timeout` fails it. AGENTS.md claimed the opposite and is corrected. The README and the changelog's capability statement both listed the missing timeout as a limitation; both now describe what the driver does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 58 +++++++++---- CHANGELOG.md | 12 +-- README.md | 6 +- src/backend.rs | 161 +++++++++++++++++++++++++++++------ src/ffi_integration_tests.rs | 106 +++++++++++++++++------ 5 files changed, 264 insertions(+), 79 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdb2ba0..fde0889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -349,9 +349,17 @@ the hook happens at connect time, so an unsupported level fails the connect. ### Cancellation -`SQLCancel` is real: `Backend::CancelToken` is `Arc<rusqlite::InterruptHandle>` -and `cancel` calls `sqlite3_interrupt`, which stops the in-flight -`sqlite3_step` on that connection. +`SQLCancel` is real: `cancel` calls `sqlite3_interrupt`, which stops the +in-flight `sqlite3_step` on that connection. + +`Backend::CancelToken` is `SqliteCancelToken`, and its two fields are scoped +differently on purpose. The interrupt handle is the *connection's*, cloned from +`connect`, because `sqlite3_interrupt` has nothing finer to aim at. The +`cancelled` flag is the *token's own*, minted fresh by `cancel_token`. Core +mints a token per statement-producing call, so a flag shared across them would +leave a cancelled statement permanently unusable, with every later error on the +connection reported as `HY008` — where the spec says "After the statement has +been canceled, the application can call SQLExecute or SQLExecDirect again." This is the **aliasing** token shape of the two `Backend::CancelToken`'s doc comment describes (the token refers to the same connection the statement is @@ -389,20 +397,36 @@ the return code. Note the gate it holds: `SQLCancel`'s idle branch clears the statement's diagnostic queue, so a cancel landing after `SQLExecDirectW` returns would wipe the `HY008` the test is reading. -`SQL_ATTR_QUERY_TIMEOUT` is substituted with `0` and reported as `01S02`, -because this driver does not override `Backend::set_query_timeout` and the -default answers `NotImplemented`. - -**This is a gap rather than an impossibility.** Core owns the timer -(`query_timer.rs`), and `Ok(QueryTimeout::CoreCancels)` asks it to arm one and -call `Backend::cancel` when the deadline passes. `cancel` is real here, which is -exactly the precondition `CoreCancels` documents. Closing the gap means -overriding `set_query_timeout` to return `CoreCancels`, and overriding -`is_cancelled` alongside it, since that is what turns the interrupted -statement's own symptom into the `HYT00` the application is waiting for rather -than the `HY008` a user-initiated `SQLCancel` produces. `SQL_ATTR_QUERY_TIMEOUT` -is a *statement* attribute while the hook receives only the connection, so read -core's scope caveat on `set_query_timeout` before doing it. +`is_cancelled` is the other half: `cancel` signals the token's flag, this +reads it, and core turns a `true` into `HY008`. Core asks only after a backend +call has already failed, so a statement that finishes before the interrupt +lands stays successful, which the spec explicitly permits. + +### Query timeout + +`SQL_ATTR_QUERY_TIMEOUT` is enforced. `set_query_timeout` answers +`QueryTimeout::CoreCancels`, so core arms its own timer (`query_timer.rs`) and +calls `Backend::cancel` when the deadline passes. SQLite has no server-side +statement deadline, so `QueryTimeout::DataSource` is unavailable; +`CoreCancels` asserts that `cancel` really cancels, which holds here. + +The deadline covers execution rather than fetching, which is where the time +goes: `exec_direct` materialises every row before returning, so a slow `SELECT` +is slow inside that call and `SQLFetch` afterwards only walks a `Vec`. + +**`HYT00` does not come from `is_cancelled`.** Core marks its own `CancelState` +timed out before cancelling, and `QueryTimer::relabel` rewrites the failed +call's SQLSTATE ahead of the `HY008` reclassification, so the more specific +timeout wins over the cancel whatever the backend reports. +`query_timeout_stops_a_long_running_statement` was verified by mutation in both +directions: stubbing `is_cancelled` to `false` leaves it passing, while +reverting `set_query_timeout` to the default fails it on the return code. + +Core's scope caveat on `set_query_timeout` does not bite here. It warns that +the hook receives only the connection, so a backend applying the value +session-wide gives every statement the most recent one. This driver applies it +nowhere: `seconds` is ignored and core owns both the timer and the stored +value, so two statements on one connection keep their own deadlines. ### `row_count` has three answers, not two diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f2e61..ccef413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,10 +38,12 @@ you commit. most people who assume a `REFERENCES` clause is a rule the database keeps. The driver issues `PRAGMA foreign_keys = ON` for every connection. -**Cancellation.** `SQLCancel` from another thread calls `sqlite3_interrupt` on -the connection, so a runaway query stops instead of running to completion while -the application believes it was cancelled. The statement reports `HY008` and -can be run again. +**Cancellation and timeouts.** `SQLCancel` from another thread calls +`sqlite3_interrupt` on the connection, so a runaway query stops instead of +running to completion while the application believes it was cancelled. The +statement reports `HY008` and can be run again. `SQL_ATTR_QUERY_TIMEOUT` is +enforced the same way, reporting `HYT00` when the deadline passes, and it +covers execution, which is where a SQLite query spends its time. **Reported capabilities.** What a driver says about itself is how applications decide which SQL to send, so the values here are measured rather than @@ -64,8 +66,6 @@ describes what was linked, including the bundled SQLite. - Rows are fetched one at a time. `SQL_ATTR_ROW_ARRAY_SIZE` and `SQL_ATTR_PARAMSET_SIZE` are both pinned at 1, so there are no block cursors and no parameter arrays. -- `SQL_ATTR_QUERY_TIMEOUT` is reported as unsupported. A running statement can - still be cancelled from another thread. - Result sets are read into memory in full, which is what lets cursors survive a commit or rollback. A `SELECT` larger than available memory will not work. - Only the serializable isolation level is offered, because it is the only one diff --git a/README.md b/README.md index e85a118..69fb0d8 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ scripted alternatives. - **The stop button stops the query.** Cancelling from your tool calls SQLite's `sqlite3_interrupt` on the connection, so a runaway query really stops rather than running to the end while your tool reports it as cancelled. The - statement can be run again afterwards. + statement can be run again afterwards. Query timeouts work the same way, so + "give up after 30 seconds" is a promise the driver keeps. - **Real transactions.** Turn autocommit off and the driver opens a transaction for you, then commits or rolls back when you say so and immediately opens the @@ -167,9 +168,6 @@ faked, so the tool can react instead of trusting a wrong answer. - **No stored procedures.** SQLite has none, so those lookups return nothing. - **Rows arrive one at a time.** There are no block cursors and no parameter arrays. -- **No query timeout.** You can cancel a running statement from another thread, - but "give up after 30 seconds" is answered with a warning rather than a - promise that would never be kept. - **Result sets are read into memory in one go.** That is what lets cursors survive a commit or rollback, but a `SELECT` over a table larger than your RAM will not work. diff --git a/src/backend.rs b/src/backend.rs index 784de66..6521fe3 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,13 +1,16 @@ //! Core type definitions for the SQLite backend ([`SqliteBackend`], -//! [`SqliteConnection`], [`SqliteStatement`]) plus `connect`, `disconnect`, -//! `end_tran`, error mapping, and the thin [`Backend`] delegation layer. -//! Statement execution, catalog metadata, `SQLGetInfo` and the DSN setup -//! dialog live in the submodules. +//! [`SqliteConnection`], [`SqliteStatement`], [`SqliteCancelToken`]) plus +//! `connect`, `disconnect`, `end_tran`, error mapping, and the thin +//! [`Backend`] delegation layer. Statement execution, catalog metadata, +//! `SQLGetInfo` and the DSN setup dialog live in the submodules. use std::{ borrow::Cow, collections::HashMap, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, }; use snafu::Snafu; @@ -17,9 +20,9 @@ use stackable_odbc_core::{ setup::{ConfigRequest, SetupError}, types::{ ColumnDescriptor, ColumnRow, ColumnValue, ConnectParams, CursorBehavior, ExecuteOutcome, - ForeignKeyRow, InfoValue, PrimaryKeyRow, SQL_CB_NULL, SQL_CN_ANY, SQL_GB_NO_RELATION, - SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TC_ALL, SQL_TXN_SERIALIZABLE, - SpecialColumnRow, StatisticsRow, TableRow, TypeInfoRow, + ForeignKeyRow, InfoValue, PrimaryKeyRow, QueryTimeout, SQL_CB_NULL, SQL_CN_ANY, + SQL_GB_NO_RELATION, SQL_IC_MIXED, SQL_NC_LOW, SQL_NNC_NON_NULL, SQL_TC_ALL, + SQL_TXN_SERIALIZABLE, SpecialColumnRow, StatisticsRow, TableRow, TypeInfoRow, }, }; @@ -64,6 +67,42 @@ pub struct SqliteConnection { pub(crate) manual_commit: std::sync::atomic::AtomicBool, } +/// What [`SqliteBackend::cancel`] signals and [`SqliteBackend::is_cancelled`] +/// observes. +/// +/// Two halves, and they are scoped differently on purpose: +/// +/// - `interrupt` is the connection's, cloned from +/// [`SqliteBackend::connect`]. `sqlite3_interrupt` stops whatever is running +/// on that connection, so there is nothing finer to hold. +/// - `cancelled` is *this token's own*, minted fresh by +/// [`SqliteBackend::cancel_token`]. It records that this particular token +/// was signalled, which is what `is_cancelled` reports. +/// +/// The freshness matters. Core mints a token per statement-producing call, and +/// a flag shared across them would leave a cancelled statement permanently +/// unusable: every later error on the connection would be reported as `HY008`, +/// where the spec says "After the statement has been canceled, the application +/// can call SQLExecute or SQLExecDirect again." A cancel that arrives for work +/// already finished therefore marks only the token it named, which is the +/// spec's own outcome: "a call to SQLCancel when no processing is being done +/// on the statement ... has no effect at all." +#[derive(Clone)] +pub struct SqliteCancelToken { + interrupt: Arc<rusqlite::InterruptHandle>, + cancelled: Arc<AtomicBool>, +} + +impl std::fmt::Debug for SqliteCancelToken { + /// `rusqlite::InterruptHandle` is not `Debug`, so this reports the only + /// part that has an observable value. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SqliteCancelToken") + .field("cancelled", &self.cancelled.load(Ordering::SeqCst)) + .finish_non_exhaustive() + } +} + pub struct SqliteStatement { /// SQL text set by `prepare()`. Present until `execute()` has run. pub(crate) prepared_sql: Option<String>, @@ -409,7 +448,7 @@ impl Backend for SqliteBackend { /// mutex*, so a racing `interrupt()` either runs against a live handle or /// sees null and does nothing. Wrapping it in this crate's own `Arc` is /// what makes the token cheap to clone per statement. - type CancelToken = Arc<rusqlite::InterruptHandle>; + type CancelToken = SqliteCancelToken; type Connection = SqliteConnection; type Error = SqliteError; type Statement = SqliteStatement; @@ -428,13 +467,22 @@ impl Backend for SqliteBackend { setup::configure_dsn(hwnd_parent, request, attributes) } - /// Hand out the connection's interrupt handle. Infallible and lock-free: - /// the handle was captured in [`SqliteBackend::connect`], so this only - /// bumps a refcount (see `SqliteConnection::interrupt`). Not an intra-doc - /// link: that field is `pub(crate)`, and rustdoc rejects a public item - /// linking to a private one. - fn cancel_token(conn: &SqliteConnection) -> Arc<rusqlite::InterruptHandle> { - Arc::clone(&conn.interrupt) + /// Hand out the connection's interrupt handle, with a fresh signal flag. + /// Infallible and lock-free: the handle was captured in + /// [`SqliteBackend::connect`], so this only bumps a refcount (see + /// `SqliteConnection::interrupt`). Not an intra-doc link: that field is + /// `pub(crate)`, and rustdoc rejects a public item linking to a private + /// one. + /// + /// The flag is new on every call rather than shared with the connection. + /// See `SqliteCancelToken` for why a shared one would strand a cancelled + /// statement. Not an intra-doc link, for the same reason as above: the + /// type is not re-exported from the crate root. + fn cancel_token(conn: &SqliteConnection) -> SqliteCancelToken { + SqliteCancelToken { + interrupt: Arc::clone(&conn.interrupt), + cancelled: Arc::new(AtomicBool::new(false)), + } } /// Interrupt whatever is running on the token's connection. @@ -452,12 +500,71 @@ impl Backend for SqliteBackend { /// entry point holds. It is also a no-op rather than an error when nothing /// is running, which is exactly what the spec asks of `SQLCancel` in that /// case. - fn cancel(token: &Arc<rusqlite::InterruptHandle>) -> Result<(), SqliteError> { + /// The flag is set *before* the interrupt, so the racing thread can never + /// observe the resulting `SQLITE_INTERRUPT` while + /// [`SqliteBackend::is_cancelled`] still answers `false`. That ordering is + /// what stops a cancelled statement reporting its raw SQLite symptom + /// instead of `HY008`. + fn cancel(token: &SqliteCancelToken) -> Result<(), SqliteError> { tracing::debug!("SQLCancel: interrupting the SQLite connection"); - token.interrupt(); + token.cancelled.store(true, Ordering::SeqCst); + token.interrupt.interrupt(); Ok(()) } + /// The other half of [`SqliteBackend::cancel`]: `cancel` signals the + /// token, this observes it, and core turns a `true` here into the `HY008` + /// the spec gives a function interrupted by `SQLCancel`. + /// + /// Core asks only after a backend call has already failed, so this never + /// turns a successful execution into an error. That matters because SQLite + /// may well finish the statement before `sqlite3_interrupt` lands, and the + /// spec allows exactly that: "it is possible for the execution to succeed + /// and return SQL_SUCCESS while the cancel is also successful." + /// + /// This is *not* what produces `HYT00` for an expired + /// `SQL_ATTR_QUERY_TIMEOUT`. Core marks its own cancel state timed-out + /// before it cancels, and relabels the failure ahead of the `HY008` + /// reclassification this feeds, so a deadline reports `HYT00` whatever + /// this answers. Implemented because + /// [`QueryTimeout::CoreCancels`] asks for the pairing, and because it + /// makes the `HY008` a property of the cancel rather than of whichever + /// `rusqlite` error happened to surface. + /// + /// Reads a flag and takes no lock, so it is safe on both of `SQLCancel`'s + /// paths for the same reason [`SqliteBackend::cancel`] is. + fn is_cancelled(token: &SqliteCancelToken) -> bool { + token.cancelled.load(Ordering::SeqCst) + } + + /// `SQL_ATTR_QUERY_TIMEOUT`, enforced by core's timer calling + /// [`SqliteBackend::cancel`]. + /// + /// SQLite has no server-side statement deadline to set, so + /// [`QueryTimeout::DataSource`] is unavailable and + /// [`QueryTimeout::CoreCancels`] is the honest answer. It asserts that + /// `cancel` really cancels, which holds here: `sqlite3_interrupt` stops + /// the in-flight `sqlite3_step`, and + /// [`SqliteBackend::is_cancelled`] is implemented alongside, as the + /// variant requires. + /// + /// The deadline covers execution rather than fetching, which is where a + /// SQLite query spends its time: `exec_direct` materialises every row + /// before returning, so a slow `SELECT` is slow inside that call and + /// `SQLFetch` afterwards only walks a `Vec`. + /// + /// `seconds` is ignored rather than pushed anywhere. Core owns the timer + /// and the value, which also sidesteps the scope caveat on this hook: the + /// deadline never becomes connection-wide state here, so two statements on + /// one connection keep their own. + fn set_query_timeout( + _conn: &SqliteConnection, + seconds: usize, + ) -> Result<QueryTimeout, SqliteError> { + tracing::debug!(seconds, "SqliteBackend::set_query_timeout"); + Ok(QueryTimeout::CoreCancels) + } + fn connect(params: &ConnectParams) -> Result<SqliteConnection, SqliteError> { let p = types::connect_params::SqliteConnectParams::try_from(params)?; let conn = rusqlite::Connection::open(p.database()).map_err(map_sqlite_error)?; @@ -982,7 +1089,7 @@ impl Backend for SqliteBackend { fn exec_direct( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, sql: &str, ) -> Result<SqliteStatement, SqliteError> { execute::exec_direct(conn, sql) @@ -990,7 +1097,7 @@ impl Backend for SqliteBackend { fn prepare( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, sql: &str, ) -> Result<SqliteStatement, SqliteError> { execute::prepare(conn, sql) @@ -998,7 +1105,7 @@ impl Backend for SqliteBackend { fn execute( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, stmt: &mut SqliteStatement, params: &[ColumnValue], ) -> Result<ExecuteOutcome, SqliteError> { @@ -1035,7 +1142,7 @@ impl Backend for SqliteBackend { fn tables( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, query: &stackable_odbc_core::types::TablesQuery<'_>, ) -> Result<Vec<TableRow>, SqliteError> { metadata::tables(conn, query) @@ -1049,7 +1156,7 @@ impl Backend for SqliteBackend { fn columns( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, query: &stackable_odbc_core::types::ColumnsQuery<'_>, ) -> Result<Vec<ColumnRow>, SqliteError> { metadata::columns(conn, query) @@ -1057,7 +1164,7 @@ impl Backend for SqliteBackend { fn primary_keys( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, query: &stackable_odbc_core::types::PrimaryKeysQuery<'_>, ) -> Result<Vec<PrimaryKeyRow>, SqliteError> { metadata::primary_keys(conn, query) @@ -1065,7 +1172,7 @@ impl Backend for SqliteBackend { fn foreign_keys( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, query: &stackable_odbc_core::types::ForeignKeysQuery<'_>, ) -> Result<Vec<ForeignKeyRow>, SqliteError> { metadata::foreign_keys(conn, query) @@ -1073,7 +1180,7 @@ impl Backend for SqliteBackend { fn statistics( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, query: &stackable_odbc_core::types::StatisticsQuery<'_>, ) -> Result<Vec<StatisticsRow>, SqliteError> { metadata::statistics(conn, query) @@ -1081,7 +1188,7 @@ impl Backend for SqliteBackend { fn special_columns( conn: &SqliteConnection, - _cancel: &Arc<rusqlite::InterruptHandle>, + _cancel: &SqliteCancelToken, query: &stackable_odbc_core::types::SpecialColumnsQuery<'_>, ) -> Result<Vec<SpecialColumnRow>, SqliteError> { metadata::special_columns(conn, query) diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 0f3a815..5e06be1 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -1745,21 +1745,18 @@ fn get_cursor_type_default_is_forward_only() { } } -/// `SQL_ATTR_QUERY_TIMEOUT`'s "no timeout" value, and the only one this driver -/// can honour. Core has the same constant privately; this names the value the -/// test asks about rather than passing a bare `0`. -const SQL_QUERY_TIMEOUT_DEFAULT: usize = 0; - -/// A requested timeout other than "no timeout" is substituted, not stored. +/// A requested timeout is accepted and stored, not substituted away. #[test] -fn set_query_timeout_is_substituted_with_no_timeout() { - // `Backend` is synchronous and this driver implements no cancellation, so - // no deadline is ever applied to a running statement. `SQL_ATTR_QUERY_TIMEOUT` - // is on the spec's 01S02 substitution list for exactly this case: the value - // is replaced with `SQL_QUERY_TIMEOUT_DEFAULT` and reported as - // SQL_SUCCESS_WITH_INFO, so an application that asks for 30 seconds can see - // it did not get them by reading the attribute back. This previously - // returned SUCCESS and echoed 30, confirming a deadline nothing enforced. +fn set_query_timeout_is_accepted_and_read_back() { + // `SqliteBackend::set_query_timeout` answers `QueryTimeout::CoreCancels`, + // so core arms its own timer and calls `Backend::cancel` when the deadline + // passes. That makes the value a real promise, and the spec's `01S02` + // substitution path no longer applies: an application asking for 30 + // seconds gets SQL_SUCCESS and reads 30 back. + // + // Reading back what was asked for is the whole point. A driver that + // silently stores something else leaves the application believing in a + // deadline it will not get. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -1772,12 +1769,8 @@ fn set_query_timeout_is_substituted_with_no_timeout() { std::ptr::without_provenance_mut(REQUESTED_TIMEOUT_SECONDS), 0, ), - SqlReturn::SUCCESS_WITH_INFO, - "an unsupported query timeout is substituted, not refused" - ); - assert_eq!( - last_sqlstate(stmt), - stackable_odbc_core::types::sql_state::OPTION_VALUE_CHANGED + SqlReturn::SUCCESS, + "a timeout this driver can honour is accepted outright" ); // `SQL_ATTR_QUERY_TIMEOUT` is a SQLUINTEGER attribute, so the driver @@ -1795,17 +1788,80 @@ fn set_query_timeout_is_substituted_with_no_timeout() { SqlReturn::SUCCESS ); assert_eq!( - val as usize, SQL_QUERY_TIMEOUT_DEFAULT, - "the substituted value has to be what the application reads back" + val as usize, REQUESTED_TIMEOUT_SECONDS, + "the stored value has to be what the application reads back" + ); + + cleanup(env, conn, stmt); + } +} + +/// A query that outruns `SQL_ATTR_QUERY_TIMEOUT` is stopped, and says so. +/// +/// End-to-end proof that `QueryTimeout::CoreCancels` is honoured: core arms +/// the timer, the timer calls [`SqliteBackend::cancel`], `sqlite3_interrupt` +/// stops the step loop, and the failed call is reported as `HYT00`. +/// +/// The `HYT00` comes from core's own timer state, not from +/// `SqliteBackend::is_cancelled`. Core marks its `CancelState` timed-out +/// before cancelling and relabels the resulting failure ahead of the +/// `HY008` reclassification, so the timeout wins over the cancel. Verified by +/// mutation: stubbing `is_cancelled` to `false` leaves this test passing, +/// while reverting `set_query_timeout` to the `NotImplemented` default fails +/// it on the return code. +#[test] +fn query_timeout_stops_a_long_running_statement() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + const TIMEOUT_SECONDS: usize = 1; + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::<SqliteBackend>( + stmt, + StatementAttribute::QueryTimeout as i32, + std::ptr::without_provenance_mut(TIMEOUT_SECONDS), + 0, + ), + SqlReturn::SUCCESS + ); + + // A recursive CTE that counts far enough to outlast the deadline + // comfortably, without allocating anything: the work is in SQLite's + // step loop, which is exactly where `sqlite3_interrupt` lands. + let rc = exec_direct( + stmt, + "WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM c WHERE i < 900000000) \ + SELECT count(*) FROM c", + ); + + assert_eq!( + rc, + SqlReturn::ERROR, + "the statement outran its timeout, so it must not report success" + ); + assert_eq!( + last_sqlstate(stmt), + stackable_odbc_core::types::sql_state::TIMEOUT_EXPIRED, + "a statement stopped by the query timer reports HYT00, not HY008" ); cleanup(env, conn, stmt); } } -/// Asking for the value the driver can honour is a plain success. +/// `SQL_ATTR_QUERY_TIMEOUT`'s "no timeout" value. Core has the same constant +/// privately; this names the value the test asks about rather than passing a +/// bare `0`. +const SQL_QUERY_TIMEOUT_DEFAULT: usize = 0; + +/// Turning the timeout off is a plain success and never reaches the backend. #[test] -fn set_query_timeout_to_no_timeout_succeeds_without_substitution() { +fn set_query_timeout_to_no_timeout_succeeds() { + // Core handles `0` without consulting `Backend::set_query_timeout` at all, + // because `0` means "no deadline" and there is nothing to arm. It is the + // default, so this also pins that clearing a timeout is not mistaken for + // requesting one. unsafe { let (env, conn, stmt) = alloc_handles(); assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); @@ -1818,7 +1874,7 @@ fn set_query_timeout_to_no_timeout_succeeds_without_substitution() { 0, ), SqlReturn::SUCCESS, - "no timeout is what this driver does, so there is nothing to substitute" + "no timeout is always available, so there is nothing to substitute" ); cleanup(env, conn, stmt); From 800f35ed9baacf8fa11baf1125e4dd58dc5a569c Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 17:19:50 +0200 Subject: [PATCH 35/50] test: add a raw C ABI pen test, and fix --skip-build standing alone The suite here went through pyodbc, so unixODBC answered a large part of the ODBC state machine before the driver ever saw it. What this driver does with an out-of-order or malformed call was untested. `test_c_abi.py` loads the `.so` with ctypes and calls the exported entry points with no Driver Manager in the loop: handle lifecycle and parentage, stale handles, double frees, use after free, cursor state, prepare/execute/re-execute, SQLFreeStmt options, the 01S02 attribute substitutions and their read-back, the enforced query timeout firing and the statement staying usable afterwards, transactions with DDL inside them, and the four catalog functions SQLite answers with an empty result set of the right shape. 120 probes. Where the spec attributes a SQLSTATE to the Driver Manager, nothing produces it here, so those probes assert what the driver does instead and name the (DM) diagnostic they are not demanding. `harness.py` and `odbc_abi.py` are the shared machinery, ported from the Trino driver. `Stack` is replaced by `Target`, which parses the connection string the suites already take, because SQLite needs no running stack to describe. Writing it turned up two things. The driver was right and the first draft was wrong about `SQLColumnPrivileges`: its `TableName` "cannot be a null pointer" per the spec, and the driver already answers `HY009` with a message that says so, which is now asserted rather than tripped over. The second is a real bug. `--skip-build` added itself to the forwarded-argument array so it would also reach `windows_test.py`, but the guard that rejects Windows-only flags on a Linux run counted it, so the documented invocation `run-tests.sh --skip-build` always failed. It is now acted on locally and appended to the forwarded arguments only when `--windows` is also passed, so the guard still catches a typo'd flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- integration-tests/README.md | 16 + integration-tests/scripts/run-tests.sh | 23 +- integration-tests/suites/harness.py | 164 +++++ integration-tests/suites/odbc_abi.py | 194 ++++++ integration-tests/suites/test_c_abi.py | 918 +++++++++++++++++++++++++ 5 files changed, 1309 insertions(+), 6 deletions(-) create mode 100644 integration-tests/suites/harness.py create mode 100644 integration-tests/suites/odbc_abi.py create mode 100644 integration-tests/suites/test_c_abi.py diff --git a/integration-tests/README.md b/integration-tests/README.md index bf3e95d..473c133 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -24,7 +24,10 @@ Both take `--help`. | `scripts/setup.sh` | Builds the driver, creates `test.db`, writes `odbc.ini` / `odbcinst.ini` | | `scripts/run-tests.sh` | Runs the suites | | `suites/create_test_db.sql` | The schema and rows every suite reads | +| `suites/harness.py` | PASS/FAIL accounting and connection-string parsing, shared by the suites | +| `suites/odbc_abi.py` | The raw ODBC C ABI declared for `ctypes`, for the suites that skip the Driver Manager | | `suites/test_integration.py` | The pyodbc suite, run once per connection style | +| `suites/test_c_abi.py` | The C ABI pen test, run once | | `generated/` | Everything `setup.sh` writes. Gitignored | | `windows/` | The VM suite, its libvirt definitions, and [WINDOWS.md](windows/WINDOWS.md) | @@ -46,6 +49,19 @@ They are separate runs because they fail separately. A driver that reads its parameters correctly can still be unreachable through a DSN, and that is a configuration most applications actually use. +Then `test_c_abi.py`, once. It loads the driver's `.so` with `ctypes` and calls +the exported entry points with **no Driver Manager in the loop**, which is the +point: unixODBC answers a large part of the ODBC state machine itself, so what +the driver does with an out-of-order or malformed call is invisible to anything +going through pyodbc. It covers handle lifecycle and parentage, stale handles +and double frees, cursor state, attribute round-trips, the query timeout, and +transactions. A DSN run would reach the same code by a longer route, so there +is only one. + +Because the spec's **(DM)** diagnostics come from the Driver Manager, that suite +never demands one. Where a SQLSTATE is (DM)-annotated it asserts what the driver +does instead, with a comment naming the diagnostic it is not asking for. + It then runs `cargo test`, so that one command gives a developer the whole suite. CI passes `--skip-cargo-test`, since its pre-commit job has already run exactly that via the `cargo-test` hook. diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh index 4a06058..b680474 100755 --- a/integration-tests/scripts/run-tests.sh +++ b/integration-tests/scripts/run-tests.sh @@ -21,17 +21,16 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" RUN_WINDOWS=false SKIP_BUILD=false SKIP_CARGO_TEST=false +# Arguments this script does not act on itself and therefore only passes along. +# `--skip-build` is deliberately absent: it is handled here, so putting it in +# this array would make the guard below reject `run-tests.sh --skip-build`, +# which is a documented Linux-only invocation. WINDOWS_ARGS=() for arg in "$@"; do case "$arg" in --windows) RUN_WINDOWS=true ;; - # Forwarded as well as acted on: the VM build is a separate - # cross-compile, and skipping one without the other would be a surprise. - --skip-build) - SKIP_BUILD=true - WINDOWS_ARGS+=("$arg") - ;; + --skip-build) SKIP_BUILD=true ;; --skip-cargo-test) SKIP_CARGO_TEST=true ;; -h | --help) usage "${BASH_SOURCE[0]}" @@ -48,6 +47,12 @@ if [[ "$RUN_WINDOWS" == false && ${#WINDOWS_ARGS[@]} -gt 0 ]]; then exit 2 fi +# Forwarded as well as acted on: the VM build is a separate cross-compile, and +# skipping one without the other would be a surprise. +if [[ "$RUN_WINDOWS" == true && "$SKIP_BUILD" == true ]]; then + WINDOWS_ARGS+=(--skip-build) +fi + require_setup if [[ "$SKIP_BUILD" == false ]]; then @@ -66,6 +71,12 @@ uv run --with pyodbc python3 "$SUITES_DIR/test_integration.py" \ echo "=== Running Linux pyodbc integration tests (DSN) ===" uv run --with pyodbc python3 "$SUITES_DIR/test_integration.py" "DSN=$DSN_NAME" +# Once, not per connection style: this suite loads the .so with ctypes and +# never reaches a Driver Manager, so a DSN run would exercise the same code by +# a longer route. Plain python3, because it needs no third-party package. +echo "=== Running raw C ABI pen test (no Driver Manager) ===" +python3 "$SUITES_DIR/test_c_abi.py" "Driver=$DRIVER_PATH;Database=$DB_PATH" + # Run by default so that a developer invoking this script gets the whole suite # in one command. CI passes --skip-cargo-test, because its pre-commit job has # already run exactly this via the cargo-test hook, and repeating it there means diff --git a/integration-tests/suites/harness.py b/integration-tests/suites/harness.py new file mode 100644 index 0000000..7ec283a --- /dev/null +++ b/integration-tests/suites/harness.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Shared machinery for the integration suites. + +Standard library only, and `pyodbc` is imported lazily inside `Target.connect`. +`test_c_abi.py` loads the driver's `.so` with `ctypes` and depends on neither a +Driver Manager, nor `uv`, nor pyodbc. A module-scope pyodbc import here would +give it all three silently. +""" + +import os +import time + + +class Results: + """PASS/FAIL/NOTE/SKIP accounting for one suite run. + + `bad` rather than `fail` so a suite is free to define its own module-level + `fail()` with different semantics; a silent collision is worse than an + unlovely name. + """ + + def __init__(self, title): + self.title = title + self.passed = 0 + self.failed = 0 + self.notes = 0 + self.skipped = 0 + + def ok(self, label, detail=""): + self.passed += 1 + print(f"PASS {label}{': ' + detail if detail else ''}") + + def bad(self, label, detail=""): + self.failed += 1 + print(f"FAIL {label}{': ' + detail if detail else ''}") + + def check(self, label, cond, detail=""): + """Record a boolean assertion. Returns the condition, so a caller can + skip dependent work without re-evaluating it.""" + if cond: + self.ok(label, detail) + else: + self.bad(label, detail) + return bool(cond) + + def run(self, label, fn): + """Run a callable, recording an exception as a failure with its + message. Prints elapsed time: a suite that slows down is a finding.""" + t0 = time.monotonic() + try: + fn() + print(f"PASS {label} ({time.monotonic() - t0:.1f}s)") + self.passed += 1 + except Exception as e: + print(f"FAIL {label} ({time.monotonic() - t0:.1f}s): {e}") + self.failed += 1 + + def note(self, label, text): + """An observation the driver is entitled to make either way. Not a + gap, and never counted as a pass.""" + self.notes += 1 + print(f"NOTE {label}: {text}") + + def skip(self, label, reason): + """A test that did not run. The reason is mandatory: an unrun test must + never be indistinguishable from a passing one.""" + self.skipped += 1 + print(f"SKIP {label}: {reason}") + + def summary(self): + parts = [f"{self.passed} passed", f"{self.failed} failed"] + if self.skipped: + parts.append(f"{self.skipped} skipped") + if self.notes: + parts.append(f"{self.notes} notes") + print(f"\n{', '.join(parts)}") + return 1 if self.failed else 0 + + +class Target: + """What a suite was pointed at, parsed from its connection-string argument. + + SQLite needs no running stack, so unlike the Trino driver's harness there is + no environment file to read: the connection string carries everything, and + `setup.sh` is what produced it. Both forms are accepted, because + `run-tests.sh` runs the pyodbc suites once per connection style: + + - `Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db` + - `DSN=test_sqlite` + + `driver_path` and `database` are only recoverable from the first. A suite + that loads the `.so` itself has to say so by calling `require_driver_path`, + which fails loudly rather than letting a DSN run reach ctypes and crash on + a `None` path. + """ + + def __init__(self, conn_str): + self.conn_str_value = conn_str + self._keys = {} + for pair in conn_str.split(";"): + key, sep, value = pair.partition("=") + if sep: + self._keys[key.strip().lower()] = value.strip() + + @classmethod + def from_argv(cls, argv, usage): + if len(argv) < 2: + raise SystemExit(usage) + return cls(argv[1]) + + def get(self, key, default=None): + return self._keys.get(key.lower(), default) + + @property + def is_dsn(self): + return "dsn" in self._keys and "driver" not in self._keys + + @property + def driver_path(self): + return self.get("driver") + + @property + def database(self): + return self.get("database") + + def require_driver_path(self): + """The driver `.so`, for a suite that loads it directly. + + A `DSN=` connection string names a data source the Driver Manager + resolves, so the library path is not in it. Exiting here beats letting + `ctypes.CDLL(None)` load the running process and fail somewhere far + less obvious. + """ + path = self.driver_path + if not path: + raise SystemExit( + "this suite loads the driver directly and needs a DSN-less " + "connection string carrying Driver=<path to the .so>\n" + f"got: {self.conn_str_value}" + ) + if not os.path.exists(path): + raise SystemExit(f"driver not found: {path}\nrun: cargo build") + return path + + def conn_str(self, **overrides): + """The connection string, with keys replaced or removed. + + An override of `None` drops the key, which is how a suite tests + connecting with, say, no `Database` at all. + """ + if not overrides: + return self.conn_str_value + merged = dict(self._keys) + for key, value in overrides.items(): + merged[key.lower()] = value + return ";".join(f"{k}={v}" for k, v in merged.items() if v is not None) + + def connect(self, **overrides): + """Connect through the Driver Manager. pyodbc is imported here rather + than at module scope so the ctypes suites keep their zero dependencies. + """ + import pyodbc + + return pyodbc.connect(self.conn_str(**overrides), autocommit=True) diff --git a/integration-tests/suites/odbc_abi.py b/integration-tests/suites/odbc_abi.py new file mode 100644 index 0000000..0264989 --- /dev/null +++ b/integration-tests/suites/odbc_abi.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""The raw ODBC C ABI, for the suites that call it without a Driver Manager. + +`test_c_abi.py` loads the driver's shared object with ctypes and calls the +exported entry points directly. This module is the plumbing: the wide-string +helper, the signature declarations, and the diagnostic readers. + +`load` takes a path, so it serves the driver's own `.so` and unixODBC's +`libodbc.so.2` equally. `SQLRETURN` is a 16-bit `SQLSMALLINT`, and an undeclared +function leaves ctypes reading the return register as a 32-bit int, where +`SQL_ERROR` arrives as 65535 and every comparison against -1 silently fails. +That is why every entry point a suite calls is declared here. +""" + +import ctypes + +# --- handle types --- +SQL_HANDLE_ENV = 1 +SQL_HANDLE_DBC = 2 +SQL_HANDLE_STMT = 3 +SQL_HANDLE_DESC = 4 + +# --- return codes --- +SQL_SUCCESS = 0 +SQL_SUCCESS_WITH_INFO = 1 +SQL_NO_DATA = 100 +SQL_ERROR = -1 +SQL_INVALID_HANDLE = -2 + +SQL_NTS = -3 +SQL_NULL_HANDLE = None + +SQL_ATTR_ODBC_VERSION = 200 +SQL_OV_ODBC3 = 3 + +# --- SQLDriverConnect DriverCompletion --- +# Only NOPROMPT forbids the driver from prompting; the other three permit it. +# pyodbc passes NOPROMPT unconditionally. +SQL_DRIVER_NOPROMPT = 0 +SQL_DRIVER_COMPLETE = 1 +SQL_DRIVER_PROMPT = 2 +SQL_DRIVER_COMPLETE_REQUIRED = 3 + + +def w(s): + """A SQLWCHAR buffer for `s`. SQLWCHAR is 16-bit on Linux. + + Returns the pointer *and* the buffer. The caller must keep the second + alive: dropping it frees the memory the pointer still refers to. + """ + buf = ctypes.create_string_buffer(s.encode("utf-16-le") + b"\x00\x00") + return ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint16)), buf + + +def load(path): + lib = ctypes.CDLL(path) + P = ctypes.c_void_p + W = ctypes.POINTER(ctypes.c_uint16) + S, I, L = ctypes.c_int16, ctypes.c_int32, ctypes.c_int64 + + sig = { + "SQLAllocHandle": ([S, P, ctypes.POINTER(P)], S), + "SQLFreeHandle": ([S, P], S), + "SQLSetEnvAttr": ([P, I, P, I], S), + "SQLGetEnvAttr": ([P, I, P, I, ctypes.POINTER(I)], S), + "SQLDriverConnectW": ([P, P, W, S, W, S, ctypes.POINTER(S), ctypes.c_uint16], S), + "SQLDisconnect": ([P], S), + "SQLExecDirectW": ([P, W, I], S), + "SQLPrepareW": ([P, W, I], S), + "SQLExecute": ([P], S), + "SQLFetch": ([P], S), + "SQLGetData": ([P, ctypes.c_uint16, S, P, L, ctypes.POINTER(L)], S), + "SQLNumResultCols": ([P, ctypes.POINTER(S)], S), + "SQLRowCount": ([P, ctypes.POINTER(L)], S), + "SQLCloseCursor": ([P], S), + "SQLFreeStmt": ([P, ctypes.c_uint16], S), + "SQLSetStmtAttrW": ([P, I, P, I], S), + "SQLGetStmtAttrW": ([P, I, P, I, ctypes.POINTER(I)], S), + "SQLSetConnectAttrW": ([P, I, P, I], S), + "SQLGetConnectAttrW": ([P, I, P, I, ctypes.POINTER(I)], S), + "SQLGetDiagRecW": ( + [S, P, S, W, ctypes.POINTER(I), W, S, ctypes.POINTER(S)], + S, + ), + "SQLGetInfoW": ([P, ctypes.c_uint16, P, S, ctypes.POINTER(S)], S), + "SQLCancel": ([P], S), + "SQLNumParams": ([P, ctypes.POINTER(S)], S), + "SQLBindParameter": ( + [P, ctypes.c_uint16, S, S, S, ctypes.c_size_t, S, P, L, ctypes.POINTER(L)], + S, + ), + # SQLRETURN is a 16-bit SQLSMALLINT: an undeclared function leaves + # ctypes reading a 32-bit register, where SQL_ERROR arrives as 65535. + "SQLEndTran": ([S, P, S], S), + "SQLTablesW": ([P, W, S, W, S, W, S, W, S], S), + "SQLColumnsW": ([P, W, S, W, S, W, S, W, S], S), + "SQLPrimaryKeysW": ([P, W, S, W, S, W, S], S), + "SQLStatisticsW": ([P, W, S, W, S, W, S, ctypes.c_uint16, ctypes.c_uint16], S), + "SQLSpecialColumnsW": ( + [P, ctypes.c_uint16, W, S, W, S, W, S, ctypes.c_uint16, ctypes.c_uint16], + S, + ), + "SQLTablePrivilegesW": ([P, W, S, W, S, W, S], S), + "SQLColumnPrivilegesW": ([P, W, S, W, S, W, S, W, S], S), + "SQLProceduresW": ([P, W, S, W, S, W, S], S), + "SQLProcedureColumnsW": ([P, W, S, W, S, W, S, W, S], S), + } + for name, (args, res) in sig.items(): + fn = getattr(lib, name) + fn.argtypes = args + fn.restype = res + return lib + + +def _diag_record(lib, htype, handle): + """Diagnostic record 1 as (sqlstate, message), or ('', '') when absent.""" + state = (ctypes.c_uint16 * 6)() + msg = (ctypes.c_uint16 * 1024)() + native = ctypes.c_int32(0) + textlen = ctypes.c_int16(0) + ret = lib.SQLGetDiagRecW( + htype, + handle, + 1, + ctypes.cast(state, ctypes.POINTER(ctypes.c_uint16)), + ctypes.byref(native), + ctypes.cast(msg, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(textlen), + ) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return "", "" + return ( + "".join(chr(c) for c in state if c).strip(), + "".join(chr(c) for c in msg[: max(textlen.value, 0)]), + ) + + +def sqlstate(lib, htype, handle): + """The SQLSTATE of diagnostic record 1, or '' when there is none.""" + return _diag_record(lib, htype, handle)[0] + + +def diag_message(lib, htype, handle): + """The message text of diagnostic record 1, or '' when there is none. + + A SQLSTATE alone is not enough to diagnose a failed connect: the SQLSTATE + says what kind of failure it was, and the message says which of several + connection-string problems produced it. + """ + return _diag_record(lib, htype, handle)[1] + + +def native_error(lib, htype, handle): + """The native error code of diagnostic record 1. + + SQLite's *extended* result code, which is what separates + `SQLITE_CONSTRAINT_NOTNULL` from `SQLITE_CONSTRAINT_FOREIGNKEY` where the + SQLSTATE cannot. `0` means the driver classified an error without keeping + the cause. + """ + state = (ctypes.c_uint16 * 6)() + msg = (ctypes.c_uint16 * 1024)() + native = ctypes.c_int32(0) + textlen = ctypes.c_int16(0) + ret = lib.SQLGetDiagRecW( + htype, + handle, + 1, + ctypes.cast(state, ctypes.POINTER(ctypes.c_uint16)), + ctypes.byref(native), + ctypes.cast(msg, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(textlen), + ) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return None + return native.value + + +def read_wide_info(lib, dbc, info_type): + """A character-shaped SQLGetInfoW answer, as a str.""" + buf = (ctypes.c_uint16 * 256)() + length = ctypes.c_int16(0) + ret = lib.SQLGetInfoW( + dbc, + info_type, + ctypes.cast(buf, ctypes.c_void_p), + 512, + ctypes.byref(length), + ) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return None + return "".join(chr(c) for c in buf[: max(length.value, 0) // 2]) diff --git a/integration-tests/suites/test_c_abi.py b/integration-tests/suites/test_c_abi.py new file mode 100644 index 0000000..9607edf --- /dev/null +++ b/integration-tests/suites/test_c_abi.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +""" +Raw C ABI pen test for the SQLite ODBC driver. + +Loads the driver's shared object with ctypes and calls its exported entry +points directly, with **no Driver Manager in the loop**. unixODBC intercepts a +large part of the ODBC state machine and answers it itself, so a driver's own +handling of an out-of-order or malformed call is invisible to any test that +goes through pyodbc or isql. Everything asserted here is the driver's own +behaviour. + +That also means the spec's **(DM)** diagnostics must not be expected. Where the +spec attributes a SQLSTATE to the Driver Manager, nothing produces it here, and +a probe that demanded it would be asserting the absence of a component rather +than the presence of a behaviour. Those probes assert what the driver does, +with a comment naming the (DM) diagnostic they do not demand. + +Covers: handle lifecycle and parentage, invalid and stale handles, double free, +use after free, connection state, cursor state, prepare / execute / re-execute, +SQLFreeStmt options, statement and connection attribute round-trips, the +enforced query timeout, transactions including DDL, and the catalog functions +SQLite answers with no rows. + +Usage: + python3 integration-tests/suites/test_c_abi.py \ + "Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db" + +Needs no server and no setup beyond a built driver: if the database file does +not exist, SQLite creates it. Only the Python standard library is used (ctypes, +not pyodbc). +""" + +import ctypes +import os +import sys + +# --- ODBC constants ------------------------------------------------------- +# Named rather than inlined, per the project's own rule about spec values. + +# The handle types, return codes and DriverCompletion values live in odbc_abi, +# which is imported below. + +# SQLFreeStmt options +SQL_CLOSE = 0 +SQL_DROP = 1 +SQL_UNBIND = 2 +SQL_RESET_PARAMS = 3 + +# Statement attributes +SQL_ATTR_QUERY_TIMEOUT = 0 +SQL_ATTR_MAX_ROWS = 1 +SQL_ATTR_NOSCAN = 2 +SQL_ATTR_MAX_LENGTH = 3 +SQL_ATTR_ASYNC_ENABLE = 4 +SQL_ATTR_CURSOR_TYPE = 6 +SQL_ATTR_CONCURRENCY = 7 +SQL_ATTR_KEYSET_SIZE = 8 +SQL_ATTR_SIMULATE_CURSOR = 10 +SQL_ATTR_RETRIEVE_DATA = 11 +SQL_ATTR_USE_BOOKMARKS = 12 +SQL_ATTR_PARAM_STATUS_PTR = 20 +SQL_ATTR_PARAMS_PROCESSED_PTR = 21 +SQL_ATTR_PARAMSET_SIZE = 22 +SQL_ATTR_ROW_ARRAY_SIZE = 27 +SQL_ATTR_CURSOR_SCROLLABLE = -1 +SQL_ATTR_CURSOR_SENSITIVITY = -2 +SQL_ATTR_METADATA_ID = 10014 +SQL_CURSOR_FORWARD_ONLY = 0 +SQL_CURSOR_STATIC = 3 + +# Values the driver substitutes to. +SQL_CONCUR_READ_ONLY = 1 +SQL_SC_NON_UNIQUE = 0 +SQL_NONSCROLLABLE = 0 +SQL_ASYNC_ENABLE_OFF = 0 + +# Connection attributes +SQL_ATTR_AUTOCOMMIT = 102 +SQL_ATTR_TXN_ISOLATION = 108 +SQL_ATTR_CURRENT_CATALOG = 109 +SQL_AUTOCOMMIT_OFF = 0 +SQL_AUTOCOMMIT_ON = 1 + +# Isolation levels: SQLite implements exactly one, and refuses the rest. +SQL_TXN_READ_UNCOMMITTED = 1 +SQL_TXN_READ_COMMITTED = 2 +SQL_TXN_REPEATABLE_READ = 4 +SQL_TXN_SERIALIZABLE = 8 + +# SQLEndTran completion types +SQL_COMMIT = 0 +SQL_ROLLBACK = 1 + +# Info types read back beside the attributes that mirror them. +SQL_DATABASE_NAME = 16 +SQL_DBMS_NAME = 17 +SQL_TXN_CAPABLE = 46 +SQL_TC_ALL = 2 + +SQL_C_CHAR = 1 +SQL_C_SBIGINT = -25 +SQL_BIGINT = -5 + +# SQLBindParameter arguments used by the bound-parameter probes. +SQL_PARAM_INPUT = 1 + +# SQLSpecialColumns / SQLStatistics arguments. +SQL_BEST_ROWID = 1 +SQL_SCOPE_CURROW = 0 +SQL_NULLABLE = 1 +SQL_INDEX_ALL = 1 +SQL_QUICK = 0 + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Target # noqa: E402 +from odbc_abi import ( # noqa: E402 + SQL_ATTR_ODBC_VERSION, + SQL_DRIVER_NOPROMPT, + SQL_ERROR, + SQL_HANDLE_DBC, + SQL_HANDLE_ENV, + SQL_HANDLE_STMT, + SQL_INVALID_HANDLE, + SQL_NO_DATA, + SQL_NTS, + SQL_OV_ODBC3, + SQL_SUCCESS, + SQL_SUCCESS_WITH_INFO, + load, + read_wide_info, + sqlstate, + w, +) + +R = Results("raw C ABI") + + +RET_NAMES = { + SQL_SUCCESS: "SUCCESS", + SQL_SUCCESS_WITH_INFO: "SUCCESS_WITH_INFO", + SQL_NO_DATA: "NO_DATA", + SQL_ERROR: "ERROR", + SQL_INVALID_HANDLE: "INVALID_HANDLE", +} + + +def rname(r): + return RET_NAMES.get(r, str(r)) + + +def check(label, got, want, state=None, got_state=None): + """Assert a return code, and optionally the SQLSTATE that came with it. + + Kept here rather than in the harness: it speaks in ODBC return codes and + SQLSTATEs, which is this suite's vocabulary, not generic machinery. + """ + want_list = want if isinstance(want, (list, tuple)) else [want] + ok = got in want_list + detail = "" + if ok and state is not None: + ok = got_state == state + detail = f" (SQLSTATE {got_state or '<none>'}, expected {state})" + elif got_state: + detail = f" (SQLSTATE {got_state})" + if ok: + R.ok(f"{label}: {rname(got)}{detail}") + else: + expect = "/".join(rname(x) for x in want_list) + R.bad(f"{label}: got {rname(got)}{detail}, expected {expect}") + + +def note(label, text): + """An observation the driver is entitled to make either way.""" + R.note(label, text) + + +def column_count(lib, stmt): + """`SQLNumResultCols` as a plain int, or -1 if the call failed.""" + cols = ctypes.c_int16(-1) + if lib.SQLNumResultCols(stmt, ctypes.byref(cols)) != SQL_SUCCESS: + return -1 + return cols.value + + +def row_count(lib, stmt): + """How many rows a result set yields, consuming it.""" + n = 0 + while lib.SQLFetch(stmt) == SQL_SUCCESS: + n += 1 + return n + + +def scalar_i64(lib, stmt, sql): + """Run `sql` and read column 1 of the first row as an integer.""" + text, _keep = w(sql) + if lib.SQLExecDirectW(stmt, text, SQL_NTS) not in ( + SQL_SUCCESS, + SQL_SUCCESS_WITH_INFO, + ): + return None + value = ctypes.c_int64(0) + ind = ctypes.c_int64(0) + got = None + if lib.SQLFetch(stmt) == SQL_SUCCESS: + if ( + lib.SQLGetData( + stmt, + 1, + SQL_C_SBIGINT, + ctypes.cast(ctypes.byref(value), ctypes.c_void_p), + 8, + ctypes.byref(ind), + ) + == SQL_SUCCESS + ): + got = value.value + lib.SQLCloseCursor(stmt) + return got + + +def main(): + target = Target.from_argv( + sys.argv, + "usage: test_c_abi.py " + '"Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db"', + ) + so = target.require_driver_path() + conn_str = target.conn_str() + + lib = load(so) + P = ctypes.c_void_p + + print(f"=== raw C ABI pen test (no Driver Manager) ===\ndriver: {so}\n") + + # --------------------------------------------------------------- + print("--- handle lifecycle ---") + env = P() + r = lib.SQLAllocHandle(SQL_HANDLE_ENV, None, ctypes.byref(env)) + check("alloc env", r, SQL_SUCCESS) + + r = lib.SQLSetEnvAttr(env, SQL_ATTR_ODBC_VERSION, P(SQL_OV_ODBC3), 0) + check("set ODBC version 3", r, SQL_SUCCESS) + + dbc = P() + r = lib.SQLAllocHandle(SQL_HANDLE_DBC, env, ctypes.byref(dbc)) + check("alloc connection", r, SQL_SUCCESS) + + # The env still owns a connection, so it must refuse to be freed. + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, env) + check( + "free env with a live connection", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_ENV, env), + ) + + # --------------------------------------------------------------- + print("\n--- invalid and mismatched handles ---") + bogus = P(0xDEADBEEF) + out = P() + r = lib.SQLAllocHandle(SQL_HANDLE_DBC, bogus, ctypes.byref(out)) + check("alloc connection on a non-handle parent", r, SQL_INVALID_HANDLE) + + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, None) + check("free a null handle", r, SQL_INVALID_HANDLE) + + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, env) + check("free an env under the wrong handle type", r, SQL_INVALID_HANDLE) + + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, env, ctypes.byref(out)) + check("alloc statement parented on an env", r, SQL_INVALID_HANDLE) + + # --------------------------------------------------------------- + print("\n--- statement on an unconnected connection ---") + stmt0 = P() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(stmt0)) + # The spec's 08003 for this is (DM)-owned, so the driver is entitled to + # allocate: a statement on a not-yet-open connection is legal here. + note("alloc statement before connecting", f"{rname(r)} (08003 here is DM-owned)") + if r == SQL_SUCCESS: + sql, _keep = w("SELECT 1") + r = lib.SQLExecDirectW(stmt0, sql, SQL_NTS) + # HY010, not 08003: SQLExecDirect's 08003 is (DM)-annotated, so with no + # Driver Manager loaded nothing produces it, and the driver reports the + # sequence error instead. + check( + "execute on an unconnected connection", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt0), + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt0) + + r = lib.SQLDisconnect(dbc) + check( + "disconnect while not connected", + r, + SQL_ERROR, + state="08003", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + # --------------------------------------------------------------- + print("\n--- connect ---") + cs, _keep_cs = w(conn_str) + outbuf = (ctypes.c_uint16 * 1024)() + outlen = ctypes.c_int16(0) + r = lib.SQLDriverConnectW( + dbc, + None, + cs, + SQL_NTS, + ctypes.cast(outbuf, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(outlen), + SQL_DRIVER_NOPROMPT, + ) + check( + "SQLDriverConnectW", + r, + [SQL_SUCCESS, SQL_SUCCESS_WITH_INFO], + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + print("\ncannot continue without a connection") + return 1 + + cs2, _keep_cs2 = w(conn_str) + r = lib.SQLDriverConnectW( + dbc, + None, + cs2, + SQL_NTS, + ctypes.cast(outbuf, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(outlen), + SQL_DRIVER_NOPROMPT, + ) + check( + "connect on an already-connected handle", + r, + SQL_ERROR, + state="08002", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + stmt = P() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(stmt)) + check("alloc statement", r, SQL_SUCCESS) + + # Dirty the connection's diagnostic queue immediately before the free, with + # no call in between. SQLite implements serializable and nothing else, so + # asking for READ COMMITTED is refused with HY024 and leaves it as record 1. + # + # It has to be immediately before. The 08002 from the failed second connect + # above is already gone by here, because SQLAllocHandle clears at entry too + # and the statement allocation sits between the two. + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_TXN_ISOLATION, P(SQL_TXN_READ_COMMITTED), 0) + check( + "set an isolation level SQLite does not implement", + r, + SQL_ERROR, + state="HY024", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + # A function clears the handle's diagnostics at entry, so the HY010 this + # posts must be record 1 rather than sitting behind the HY024 above. An + # application reading the first record after a failed free would otherwise + # act on the previous call's SQLSTATE. + r = lib.SQLFreeHandle(SQL_HANDLE_DBC, dbc) + check( + "free connection while still connected, over a dirty queue", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + # The level SQLite does implement is accepted, which is the other half of + # the HY024 above: a driver that refused everything would pass that probe + # while offering no isolation at all. + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_TXN_ISOLATION, P(SQL_TXN_SERIALIZABLE), 0) + check("set SQL_TXN_SERIALIZABLE, the level SQLite implements", r, SQL_SUCCESS) + + # --------------------------------------------------------------- + print("\n--- cursor state with no cursor ---") + # HY010, not 24000. 24000 is for a statement that *was* executed but has no + # result set, HY010 for one never put in an executed state. This statement + # is the latter. + r = lib.SQLFetch(stmt) + check( + "fetch on a never-executed statement", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + ind = ctypes.c_int64(0) + buf = ctypes.create_string_buffer(64) + r = lib.SQLGetData(stmt, 1, SQL_C_CHAR, ctypes.cast(buf, P), 64, ctypes.byref(ind)) + check( + "get_data with no cursor", + r, + SQL_ERROR, + state="24000", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + r = lib.SQLCloseCursor(stmt) + check( + "close_cursor with no cursor", + r, + SQL_ERROR, + state="24000", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + r = lib.SQLExecute(stmt) + check( + "execute with nothing prepared", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + cols = ctypes.c_int16(-1) + r = lib.SQLNumResultCols(stmt, ctypes.byref(cols)) + check( + "num_result_cols before execute", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + # --------------------------------------------------------------- + print("\n--- prepare / execute / re-execute ---") + sql, _k1 = w("SELECT 1 AS n") + r = lib.SQLPrepareW(stmt, sql, SQL_NTS) + check("prepare", r, SQL_SUCCESS) + + # SQL_ATTR_CURSOR_TYPE may not be set once a statement is prepared. + r = lib.SQLSetStmtAttrW(stmt, SQL_ATTR_CURSOR_TYPE, P(SQL_CURSOR_STATIC), 0) + check( + "set cursor type after prepare", + r, + SQL_ERROR, + state="HY011", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + for attempt in (1, 2): + r = lib.SQLExecute(stmt) + check(f"execute (attempt {attempt})", r, SQL_SUCCESS) + r = lib.SQLNumResultCols(stmt, ctypes.byref(cols)) + check(f"num_result_cols after execute ({attempt})", r, SQL_SUCCESS) + if cols.value != 1: + note(f"num_result_cols after execute ({attempt})", f"got {cols.value}") + r = lib.SQLFetch(stmt) + check(f"fetch row ({attempt})", r, SQL_SUCCESS) + r = lib.SQLFetch(stmt) + check(f"fetch past the last row ({attempt})", r, SQL_NO_DATA) + r = lib.SQLCloseCursor(stmt) + check(f"close cursor ({attempt})", r, SQL_SUCCESS) + + # --------------------------------------------------------------- + print("\n--- SQLFreeStmt options ---") + sql, _k2 = w("SELECT 1 AS n") + lib.SQLExecDirectW(stmt, sql, SQL_NTS) + r = lib.SQLFreeStmt(stmt, SQL_CLOSE) + check("free_stmt SQL_CLOSE with an open cursor", r, SQL_SUCCESS) + r = lib.SQLFreeStmt(stmt, SQL_CLOSE) + check("free_stmt SQL_CLOSE with no cursor", r, SQL_SUCCESS) + r = lib.SQLFreeStmt(stmt, SQL_UNBIND) + check("free_stmt SQL_UNBIND", r, SQL_SUCCESS) + r = lib.SQLFreeStmt(stmt, SQL_RESET_PARAMS) + check("free_stmt SQL_RESET_PARAMS", r, SQL_SUCCESS) + # The SQLSTATE matters as much as the return code: an SQL_ERROR carrying no + # diagnostic record leaves an application with an error it cannot interpret. + r = lib.SQLFreeStmt(stmt, 99) + check( + "free_stmt with an undefined option", + r, + SQL_ERROR, + state="HY092", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + # --------------------------------------------------------------- + print("\n--- statement attributes: substituted values (01S02) ---") + # The spec's 01S02 row closes the set of statement attributes a driver may + # substitute for. For each, the driver must store the value it will use, + # which is what makes the row's parenthesis true: "(SQLGetStmtAttr can be + # called to determine the temporarily substituted value.)". The read-back is + # asserted, not merely observed. A driver that kept the requested value + # would be claiming a block cursor it does not implement, and an application + # reading its own number back has no way to tell. + # + # SQL_ATTR_QUERY_TIMEOUT is absent from this list because the driver + # enforces it; it is checked on its own below. + # + # SQLULEN is 64-bit here, so the read-back buffer is too, and it is zeroed + # before each read. + # + # A *fresh* statement, not the shared one: SQL_ATTR_CONCURRENCY, + # SQL_ATTR_CURSOR_TYPE, SQL_ATTR_SIMULATE_CURSOR and SQL_ATTR_USE_BOOKMARKS + # "must be set before the statement is executed", and the shared handle has + # been prepared and executed by now. + val = ctypes.c_uint64(0) + outlen32 = ctypes.c_int32(0) + attr_stmt = ctypes.c_void_p() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(attr_stmt)) + check("allocate a fresh statement for the attribute probes", r, SQL_SUCCESS) + for label, attr, asked, substituted in ( + ("SQL_ATTR_CONCURRENCY", SQL_ATTR_CONCURRENCY, 2, SQL_CONCUR_READ_ONLY), + ( + "SQL_ATTR_CURSOR_TYPE", + SQL_ATTR_CURSOR_TYPE, + SQL_CURSOR_STATIC, + SQL_CURSOR_FORWARD_ONLY, + ), + ("SQL_ATTR_KEYSET_SIZE", SQL_ATTR_KEYSET_SIZE, 50, 0), + ("SQL_ATTR_MAX_LENGTH", SQL_ATTR_MAX_LENGTH, 4096, 0), + ("SQL_ATTR_MAX_ROWS", SQL_ATTR_MAX_ROWS, 100, 0), + # No block cursors: core pins the rowset at 1, which is why the driver + # reports rows one at a time. + ("SQL_ATTR_ROW_ARRAY_SIZE", SQL_ATTR_ROW_ARRAY_SIZE, 10, 1), + ("SQL_ATTR_SIMULATE_CURSOR", SQL_ATTR_SIMULATE_CURSOR, 2, SQL_SC_NON_UNIQUE), + # Two deviations from the spec's closed list, documented in core. + # Substituting keeps SQL_ATTR_CURSOR_SCROLLABLE consistent with + # SQL_ATTR_CURSOR_TYPE. For SQL_ATTR_PARAMSET_SIZE, refusing would fail + # a call every parameter-array-capable tool makes, while accepting it + # verbatim would silently drop every set past the first. + ("SQL_ATTR_CURSOR_SCROLLABLE", SQL_ATTR_CURSOR_SCROLLABLE, 1, SQL_NONSCROLLABLE), + ("SQL_ATTR_PARAMSET_SIZE", SQL_ATTR_PARAMSET_SIZE, 500, 1), + ): + r = lib.SQLSetStmtAttrW(attr_stmt, attr, P(asked), 0) + check( + f"set {label}={asked} (unsupported)", + r, + SQL_SUCCESS_WITH_INFO, + state="01S02", + got_state=sqlstate(lib, SQL_HANDLE_STMT, attr_stmt), + ) + val.value = 0 + r = lib.SQLGetStmtAttrW( + attr_stmt, attr, ctypes.byref(val), 8, ctypes.byref(outlen32) + ) + check(f"get {label}", r, SQL_SUCCESS) + if r == SQL_SUCCESS: + check( + f"{label} reads back the substituted value", + SQL_SUCCESS if val.value == substituted else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {val.value}, expected {substituted}", + ) + + # --------------------------------------------------------------- + print("\n--- SQL_ATTR_QUERY_TIMEOUT is enforced, not substituted ---") + # The one attribute on the 01S02 list this driver honours. Core arms its own + # timer and calls Backend::cancel, which reaches sqlite3_interrupt, so + # SQLGetStmtAttr reporting 42 rather than 0 is how an application learns the + # deadline is really in force. + # + # Core arms it from a timer thread inside the .so, so no Driver Manager + # threading policy can serialise it: this suite exercises the same path a + # unixODBC client gets. + r = lib.SQLSetStmtAttrW(attr_stmt, SQL_ATTR_QUERY_TIMEOUT, P(42), 0) + check( + "set SQL_ATTR_QUERY_TIMEOUT=42 (enforced, not substituted)", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, attr_stmt), + ) + val.value = 0 + r = lib.SQLGetStmtAttrW( + attr_stmt, SQL_ATTR_QUERY_TIMEOUT, ctypes.byref(val), 8, ctypes.byref(outlen32) + ) + check("get SQL_ATTR_QUERY_TIMEOUT", r, SQL_SUCCESS) + check( + "SQL_ATTR_QUERY_TIMEOUT reads back what was asked for", + SQL_SUCCESS if val.value == 42 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {val.value}, expected 42", + ) + + # And it actually fires. A recursive CTE counts far enough to outlast a + # one-second deadline without allocating anything, so the work sits in + # SQLite's step loop, which is exactly where sqlite3_interrupt lands. + # + # HYT00, not HY008: core marks its own cancel state timed out before + # cancelling and relabels the failure ahead of the HY008 reclassification, + # so the more specific timeout wins over the cancel. + timeout_stmt = P() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(timeout_stmt)) + check("allocate a statement for the query-timeout probe", r, SQL_SUCCESS) + r = lib.SQLSetStmtAttrW(timeout_stmt, SQL_ATTR_QUERY_TIMEOUT, P(1), 0) + check("set a 1-second deadline", r, SQL_SUCCESS) + slow, _k_slow = w( + "WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM c " + "WHERE i < 900000000) SELECT count(*) FROM c" + ) + r = lib.SQLExecDirectW(timeout_stmt, slow, SQL_NTS) + check( + "a query that outruns its deadline is stopped", + r, + SQL_ERROR, + state="HYT00", + got_state=sqlstate(lib, SQL_HANDLE_STMT, timeout_stmt), + ) + # The spec requires a cancelled statement to stay usable: "After the + # statement has been canceled, the application can call SQLExecute or + # SQLExecDirect again." A driver whose cancel flag stuck would fail here. + r = lib.SQLSetStmtAttrW(timeout_stmt, SQL_ATTR_QUERY_TIMEOUT, P(0), 0) + check("clear the deadline", r, SQL_SUCCESS) + check( + "the timed-out statement runs again", + SQL_SUCCESS if scalar_i64(lib, timeout_stmt, "SELECT 7") == 7 else SQL_ERROR, + SQL_SUCCESS, + ) + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, timeout_stmt) + check("free the timed-out statement", r, SQL_SUCCESS) + + # --------------------------------------------------------------- + print("\n--- connection attributes ---") + r = lib.SQLGetConnectAttrW( + dbc, SQL_ATTR_AUTOCOMMIT, ctypes.byref(val), 8, ctypes.byref(outlen32) + ) + check("get SQL_ATTR_AUTOCOMMIT", r, SQL_SUCCESS) + check( + "autocommit defaults to on", + SQL_SUCCESS if val.value == SQL_AUTOCOMMIT_ON else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {val.value}", + ) + + # SQLite has no catalogs, so there is no current one to report. Either + # answer is defensible: an empty string, or a refusal. What would be wrong + # is naming a catalog the driver also says does not exist. + catalog_buf = (ctypes.c_uint16 * 256)() + r = lib.SQLGetConnectAttrW( + dbc, + SQL_ATTR_CURRENT_CATALOG, + ctypes.cast(catalog_buf, P), + 512, + ctypes.byref(outlen32), + ) + if r in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + got = "".join(chr(c) for c in catalog_buf[: max(outlen32.value, 0) // 2]) + check( + "SQL_ATTR_CURRENT_CATALOG names no catalog", + SQL_SUCCESS if got == "" else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {got!r}", + ) + else: + note( + "get SQL_ATTR_CURRENT_CATALOG", + f"{rname(r)} ({sqlstate(lib, SQL_HANDLE_DBC, dbc)}); " + "SQLite has no catalogs, so refusing is a fair answer", + ) + + # --------------------------------------------------------------- + print("\n--- transactions, including DDL ---") + # SQL_TXN_CAPABLE is SQL_TC_ALL, which the spec defines as "Transactions + # support both DML and DDL statements in any order". This is that claim + # exercised through the C ABI rather than only asserted in a unit test: a + # CREATE TABLE between two inserts, then a rollback that undoes all three. + info = ctypes.c_uint16(0) + length = ctypes.c_int16(0) + r = lib.SQLGetInfoW( + dbc, SQL_TXN_CAPABLE, ctypes.byref(info), 2, ctypes.byref(length) + ) + check("get SQL_TXN_CAPABLE", r, SQL_SUCCESS) + check( + "SQL_TXN_CAPABLE is SQL_TC_ALL", + SQL_SUCCESS if info.value == SQL_TC_ALL else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {info.value}, expected {SQL_TC_ALL}", + ) + + txn = P() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(txn)) + check("allocate a statement for the transaction probe", r, SQL_SUCCESS) + + setup_sql, _k_setup = w("CREATE TABLE IF NOT EXISTS abi_txn (id INTEGER)") + lib.SQLExecDirectW(txn, setup_sql, SQL_NTS) + clear_sql, _k_clear = w("DELETE FROM abi_txn") + lib.SQLExecDirectW(txn, clear_sql, SQL_NTS) + + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_AUTOCOMMIT, P(SQL_AUTOCOMMIT_OFF), 0) + check("turn autocommit off", r, SQL_SUCCESS) + + for sql_text in ( + "INSERT INTO abi_txn VALUES (1)", + "CREATE TABLE abi_mid (x TEXT)", + "INSERT INTO abi_txn VALUES (2)", + ): + text, _keep = w(sql_text) + r = lib.SQLExecDirectW(txn, text, SQL_NTS) + check( + f"in a transaction: {sql_text.split(' ')[0]} {sql_text.split(' ')[1]}", + r, + [SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SQL_NO_DATA], + got_state=sqlstate(lib, SQL_HANDLE_STMT, txn), + ) + + r = lib.SQLEndTran(SQL_HANDLE_DBC, dbc, SQL_ROLLBACK) + check("roll the transaction back", r, SQL_SUCCESS) + + check( + "the rollback undid the rows around the DDL", + SQL_SUCCESS if scalar_i64(lib, txn, "SELECT count(*) FROM abi_txn") == 0 else SQL_ERROR, + SQL_SUCCESS, + ) + survived = scalar_i64( + lib, txn, "SELECT count(*) FROM sqlite_master WHERE name = 'abi_mid'" + ) + check( + "the rollback undid the DDL too", + SQL_SUCCESS if survived == 0 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"abi_mid rows in sqlite_master: {survived}", + ) + + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_AUTOCOMMIT, P(SQL_AUTOCOMMIT_ON), 0) + check("turn autocommit back on", r, SQL_SUCCESS) + drop_sql, _k_drop = w("DROP TABLE IF EXISTS abi_txn") + lib.SQLExecDirectW(txn, drop_sql, SQL_NTS) + lib.SQLFreeHandle(SQL_HANDLE_STMT, txn) + + # --------------------------------------------------------------- + print("\n--- catalog functions SQLite answers with no rows ---") + # Each of these describes a concept SQLite does not have. The spec requires + # the result set's *shape* regardless, so the column count is the real + # assertion: a driver returning zero columns has failed the call rather than + # answered it, and an application cannot tell the difference from the row + # count alone. + # `SQLColumnPrivileges` is the one that needs a table name: the spec says + # its TableName "cannot be a null pointer", unlike the pattern arguments of + # the other three. It is named here so the probe tests the empty result set + # rather than the argument check, which is asserted separately below. + priv_table, _k_priv = w("users") + for label, call, columns in ( + ( + "table privileges", + lambda s: lib.SQLTablePrivilegesW(s, None, 0, None, 0, None, 0), + 7, + ), + ( + "column privileges", + lambda s: lib.SQLColumnPrivilegesW( + s, None, 0, None, 0, priv_table, SQL_NTS, None, 0 + ), + 8, + ), + ("procedures", lambda s: lib.SQLProceduresW(s, None, 0, None, 0, None, 0), 8), + ( + "procedure columns", + lambda s: lib.SQLProcedureColumnsW(s, None, 0, None, 0, None, 0, None, 0), + 19, + ), + ): + cat = P() + lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(cat)) + r = call(cat) + check(f"{label} succeeds", r, [SQL_SUCCESS, SQL_SUCCESS_WITH_INFO]) + got_cols = column_count(lib, cat) + check( + f"{label} describes {columns} columns", + SQL_SUCCESS if got_cols == columns else SQL_ERROR, + SQL_SUCCESS, + got_state=f"got {got_cols}", + ) + rows = row_count(lib, cat) + check( + f"{label} is empty", + SQL_SUCCESS if rows == 0 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"got {rows} rows", + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, cat) + + # The required-argument check, which the loop above deliberately satisfies. + # HY009 is the driver's own: the spec attributes this one to the driver, not + # to the Driver Manager, so it is fair to demand it here. + null_arg = P() + lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(null_arg)) + r = lib.SQLColumnPrivilegesW(null_arg, None, 0, None, 0, None, 0, None, 0) + check( + "column privileges rejects a null table name", + r, + SQL_ERROR, + state="HY009", + got_state=sqlstate(lib, SQL_HANDLE_STMT, null_arg), + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, null_arg) + + # --------------------------------------------------------------- + print("\n--- bound parameters ---") + param_stmt = P() + lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(param_stmt)) + psql, _k_p = w("SELECT ? + 1") + r = lib.SQLPrepareW(param_stmt, psql, SQL_NTS) + check("prepare a statement with one parameter", r, SQL_SUCCESS) + + nparams = ctypes.c_int16(-1) + r = lib.SQLNumParams(param_stmt, ctypes.byref(nparams)) + check("num_params", r, SQL_SUCCESS) + check( + "num_params counts the marker", + SQL_SUCCESS if nparams.value == 1 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"got {nparams.value}", + ) + + pval = ctypes.c_int64(41) + plen = ctypes.c_int64(8) + r = lib.SQLBindParameter( + param_stmt, + 1, + SQL_PARAM_INPUT, + SQL_C_SBIGINT, + SQL_BIGINT, + 0, + 0, + ctypes.cast(ctypes.byref(pval), P), + 8, + ctypes.byref(plen), + ) + check("bind the parameter", r, SQL_SUCCESS) + + r = lib.SQLExecute(param_stmt) + check("execute with one parameter set", r, SQL_SUCCESS) + got = None + if lib.SQLFetch(param_stmt) == SQL_SUCCESS: + result = ctypes.c_int64(0) + rind = ctypes.c_int64(0) + if ( + lib.SQLGetData( + param_stmt, + 1, + SQL_C_SBIGINT, + ctypes.cast(ctypes.byref(result), P), + 8, + ctypes.byref(rind), + ) + == SQL_SUCCESS + ): + got = result.value + check( + "the bound value reached SQLite", + SQL_SUCCESS if got == 42 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"got {got}, expected 42", + ) + lib.SQLCloseCursor(param_stmt) + lib.SQLFreeHandle(SQL_HANDLE_STMT, param_stmt) + + # --------------------------------------------------------------- + print("\n--- cancel and teardown ---") + # "A call to SQLCancel when no processing is being done on the statement + # ... has no effect at all", so an idle cancel succeeds. + r = lib.SQLCancel(stmt) + check("cancel an idle statement", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, attr_stmt) + check("free the attribute statement", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + check("free statement", r, SQL_SUCCESS) + + # The handle is gone, so its tag no longer validates. Nothing here may + # dereference freed memory: the whole point of the tag is that a stale + # handle is detected rather than followed. + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + check("free the same statement twice", r, SQL_INVALID_HANDLE) + + r = lib.SQLFetch(stmt) + check("fetch on a freed statement", r, SQL_INVALID_HANDLE) + + r = lib.SQLExecute(stmt) + check("execute on a freed statement", r, SQL_INVALID_HANDLE) + + dbms = read_wide_info(lib, dbc, SQL_DBMS_NAME) + check( + "SQL_DBMS_NAME says SQLite", + SQL_SUCCESS if dbms == "SQLite" else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {dbms!r}", + ) + + r = lib.SQLDisconnect(dbc) + check("disconnect", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_DBC, dbc) + check("free connection", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_DBC, dbc) + check("free the same connection twice", r, SQL_INVALID_HANDLE) + + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, env) + check("free env once its children are gone", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, env) + check("free the same env twice", r, SQL_INVALID_HANDLE) + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) From df0bdb922d5a640005a4ef7d9020366893fff47c Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 17:34:23 +0200 Subject: [PATCH 36/50] test: add a transactions suite, and record a computed-column typing finding Manual-commit transactions had FFI-level coverage but nothing exercising them through a Driver Manager, where an application meets them. Three of the eight scenarios assert the opposite of the Trino driver's, which is why the suite could not be copied across: - A failed statement leaves a SQLite transaction usable. Trino aborts the whole thing and refuses the commit, so its driver rolls back and reports 25S03. Here the commit must succeed and publish the earlier writes; a driver that rolled back to look consistent would discard writes silently. - A commit preserves an open cursor. `SQL_CURSOR_COMMIT_BEHAVIOR` is `SQL_CB_PRESERVE`, true only because `exec_direct` materialises eagerly, so the scenario fetches on after the commit and expects the rest of the rows. - Serializable is the level that must be accepted, and the other three refused with HY024. Trino is the mirror image. Both halves are asserted: refusing everything would pass a rejection-only check while offering no isolation at all. Also covered: rollback and commit visibility from a second connection, atomicity across two tables, autocommit as the default, and DDL inside a transaction being undone by a rollback, which is `SQL_TC_ALL` seen from the application's side rather than through the C ABI. Writing it surfaced a finding that is not fixed here. `count(*)` comes back as a *string*: `sqlite3_column_decltype` is NULL for any computed column, and `describe_column` falls back to `TEXT`, so every expression is described as VARCHAR whatever the storage class of its value. Aggregates are a common enough shape that a BI tool would see text where it expects a number. The suite coerces with a documented `as_int` so a transaction failure is never reported as a typing failure, and the fix belongs with the type-matrix suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- integration-tests/README.md | 8 + integration-tests/scripts/run-tests.sh | 9 + integration-tests/suites/test_transactions.py | 410 ++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 integration-tests/suites/test_transactions.py diff --git a/integration-tests/README.md b/integration-tests/README.md index 473c133..5e84c36 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -27,6 +27,7 @@ Both take `--help`. | `suites/harness.py` | PASS/FAIL accounting and connection-string parsing, shared by the suites | | `suites/odbc_abi.py` | The raw ODBC C ABI declared for `ctypes`, for the suites that skip the Driver Manager | | `suites/test_integration.py` | The pyodbc suite, run once per connection style | +| `suites/test_transactions.py` | Manual-commit transactions, run once per connection style | | `suites/test_c_abi.py` | The C ABI pen test, run once | | `generated/` | Everything `setup.sh` writes. Gitignored | | `windows/` | The VM suite, its libvirt definitions, and [WINDOWS.md](windows/WINDOWS.md) | @@ -49,6 +50,13 @@ They are separate runs because they fail separately. A driver that reads its parameters correctly can still be unreachable through a DSN, and that is a configuration most applications actually use. +`test_transactions.py` runs the same two ways, because manual-commit mode is +set on the connection. Three of its scenarios assert the *opposite* of the +Trino driver's equivalents, which is why it could not simply be copied across: +a failed statement leaves a SQLite transaction usable rather than aborting it, +a commit preserves an open cursor rather than closing it, and serializable is +the level that must be accepted rather than refused. + Then `test_c_abi.py`, once. It loads the driver's `.so` with `ctypes` and calls the exported entry points with **no Driver Manager in the loop**, which is the point: unixODBC answers a large part of the ODBC state machine itself, so what diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh index b680474..5944625 100755 --- a/integration-tests/scripts/run-tests.sh +++ b/integration-tests/scripts/run-tests.sh @@ -71,6 +71,15 @@ uv run --with pyodbc python3 "$SUITES_DIR/test_integration.py" \ echo "=== Running Linux pyodbc integration tests (DSN) ===" uv run --with pyodbc python3 "$SUITES_DIR/test_integration.py" "DSN=$DSN_NAME" +# Both connection styles again: manual-commit mode is set on the connection, so +# the DSN path is worth exercising even though the transaction logic is shared. +echo "=== Running transaction tests (DSN-less) ===" +uv run --with pyodbc python3 "$SUITES_DIR/test_transactions.py" \ + "Driver=$DRIVER_PATH;Database=$DB_PATH" + +echo "=== Running transaction tests (DSN) ===" +uv run --with pyodbc python3 "$SUITES_DIR/test_transactions.py" "DSN=$DSN_NAME" + # Once, not per connection style: this suite loads the .so with ctypes and # never reaches a Driver Manager, so a DSN run would exercise the same code by # a longer route. Plain python3, because it needs no third-party package. diff --git a/integration-tests/suites/test_transactions.py b/integration-tests/suites/test_transactions.py new file mode 100644 index 0000000..7ffdb78 --- /dev/null +++ b/integration-tests/suites/test_transactions.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""ODBC manual-commit transactions, through unixODBC. + +Needs no server: the database is a file, and `setup.sh` made it. Every scenario +works in tables of its own and drops them, so it can run against the shared +test database without disturbing the other suites. + +Four measured SQLite behaviours shape what is asserted here, and three of them +are the *opposite* of the Trino driver's, so a scenario copied across without +thinking would assert the wrong thing: + + - **A failed statement does not abort the transaction.** Trino discards the + whole thing and then refuses the commit. SQLite leaves the transaction open + and the earlier writes intact, so the commit must succeed and publish them. + - **A commit does not close an open cursor.** The driver reports + `SQL_CURSOR_COMMIT_BEHAVIOR = SQL_CB_PRESERVE`, which is only true because + `exec_direct` materialises every row before returning. Fetching must go on + working after the commit. + - **DDL participates in the transaction.** `SQL_TXN_CAPABLE` is `SQL_TC_ALL`, + so a `CREATE TABLE` inside a transaction is undone by a rollback along with + the rows around it. + - **Serializable is the only isolation level**, so it is the one that must be + accepted and every other one must be refused. Trino is the mirror image, + offering only `READ UNCOMMITTED`. + +Usage: + python3 integration-tests/suites/test_transactions.py \ + "Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db" +""" + +import os +import sys +import time +import uuid + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import Results, Target # noqa: E402 + +# pyodbc enables ODBC connection pooling by default, and a pooled connection is +# handed back to the application without the driver being reconnected, so it +# arrives still carrying whatever commit mode the previous borrower left on it. +# A "fresh" connection would then run inside the previous borrower's +# manual-commit mode, and a write would be discarded while reporting success. +# +# Turned off so this suite measures the driver rather than the Driver Manager's +# pooling. +pyodbc.pooling = False + +# pyodbc exposes neither of these, so they are spelled out rather than taken +# from it. +SQL_ATTR_TXN_ISOLATION = 108 +SQL_TXN_READ_UNCOMMITTED = 1 +SQL_TXN_READ_COMMITTED = 2 +SQL_TXN_REPEATABLE_READ = 4 +SQL_TXN_SERIALIZABLE = 8 + + +def scenario(results, label, fn): + """Run one scenario, recording an exception as a single failure. + + `Results.run` is not used because each scenario does its own `check` + accounting, and a wrapper PASS printed beside an inner FAIL reads as though + something passed.""" + start = time.monotonic() + try: + fn() + except Exception as e: # noqa: BLE001 + results.bad(label, f"raised after {time.monotonic() - start:.1f}s: {e}") + else: + print(f" {label}: {time.monotonic() - start:.1f}s") + + +def unique_table(prefix): + """A table name of this run's own, so a suite left half-finished by an + earlier failure cannot make the next run pass or fail for the wrong + reason.""" + return f"tx_{prefix}_{uuid.uuid4().hex[:8]}" + + +def make_table(conn, table): + conn.cursor().execute(f"CREATE TABLE {table} (id INTEGER)") + + +def drop_table(conn, table): + try: + conn.cursor().execute(f"DROP TABLE IF EXISTS {table}") + except Exception: # noqa: BLE001 + # Cleanup only. A failure here must not mask the scenario's own result. + pass + + +def as_int(value): + """An aggregate read back as a number, whatever the driver typed it as. + + `count(*)` arrives as a *string*: `sqlite3_column_decltype` is NULL for any + computed column, and `describe_column` falls back to `TEXT`, so every + expression is described as VARCHAR regardless of the storage class of the + value in it. That is a real finding, but it belongs to the type suite, not + to this one. Coercing here keeps a transaction failure from being reported + as a typing failure and the other way round.""" + return int(value) + + +def count_rows(target, table): + """Count from a *fresh* connection, which is what makes a commit or a + rollback observable rather than merely reported. + + Only ever called once the writing transaction has ended. SQLite takes a + write lock for the duration of one, and a second connection reading through + it would be answered `SQLITE_BUSY` rather than with a row count.""" + with target.connect() as conn: + return as_int( + conn.cursor().execute(f"SELECT count(*) FROM {table}").fetchone()[0] + ) + + +def table_exists(target, table): + with target.connect() as conn: + return ( + as_int( + conn.cursor() + .execute("SELECT count(*) FROM sqlite_master WHERE name = ?", table) + .fetchone()[0] + ) + > 0 + ) + + +def a_rollback_discards_a_write(target, results): + table = unique_table("rollback") + with target.connect() as setup: + make_table(setup, table) + try: + conn = target.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + conn.rollback() + conn.close() + + count = count_rows(target, table) + results.check("a rolled-back insert is not there", count == 0, f"count is {count}") + finally: + with target.connect() as cleanup: + drop_table(cleanup, table) + + +def a_commit_publishes_a_write(target, results): + table = unique_table("commit") + with target.connect() as setup: + make_table(setup, table) + try: + conn = target.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + conn.commit() + conn.close() + + count = count_rows(target, table) + results.check( + "a committed insert is visible to another connection", + count == 1, + f"count is {count}", + ) + finally: + with target.connect() as cleanup: + drop_table(cleanup, table) + + +def a_commit_spanning_two_tables_is_atomic(target, results): + first, second = unique_table("atomic_a"), unique_table("atomic_b") + with target.connect() as setup: + make_table(setup, first) + make_table(setup, second) + try: + conn = target.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {first} VALUES (1)") + conn.cursor().execute(f"INSERT INTO {second} VALUES (2)") + conn.commit() + conn.close() + + a, b = count_rows(target, first), count_rows(target, second) + results.check("both tables carry the commit", a == 1 and b == 1, f"{a} and {b}") + finally: + with target.connect() as cleanup: + drop_table(cleanup, first) + drop_table(cleanup, second) + + +def a_failed_statement_leaves_the_transaction_usable(target, results): + """The inverse of the Trino driver's equivalent, and the reason this suite + could not be copied across. + + Trino aborts the whole transaction on any statement error and then refuses + the commit, so its driver rolls back and reports `25S03`. SQLite does no + such thing: a statement that fails to prepare or resolve leaves the + transaction open and the earlier writes intact. The commit must therefore + succeed and publish them. + + A driver that rolled back here to look consistent with the Trino one would + silently discard writes the application was told nothing about.""" + table = unique_table("survives") + with target.connect() as setup: + make_table(setup, table) + try: + conn = target.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + + try: + conn.cursor().execute("SELECT * FROM a_table_that_does_not_exist").fetchall() + results.bad("the bad statement fails", "it succeeded") + conn.close() + return + except Exception: # noqa: BLE001 + pass + + try: + conn.commit() + except Exception as e: # noqa: BLE001 + results.bad( + "committing after a failed statement succeeds", + f"it raised {type(e).__name__}: {e}", + ) + conn.close() + return + + count = count_rows(target, table) + results.check( + "the write made before the failed statement survives the commit", + count == 1, + f"count is {count}", + ) + + conn.autocommit = True + value = as_int(conn.cursor().execute("SELECT 1").fetchone()[0]) + results.check("the connection still works", value == 1, f"got {value}") + conn.close() + finally: + with target.connect() as cleanup: + drop_table(cleanup, table) + + +def ddl_inside_a_transaction_is_rolled_back(target, results): + """`SQL_TXN_CAPABLE` is `SQL_TC_ALL`: "Transactions support both DML and DDL + statements in any order". + + The C ABI suite proves this without a Driver Manager. Here it goes through + unixODBC as an application would, because the claim is what a tool reads + before deciding whether it may run DDL inside a transaction at all.""" + outer, created = unique_table("ddl_outer"), unique_table("ddl_inner") + with target.connect() as setup: + make_table(setup, outer) + try: + conn = target.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {outer} VALUES (1)") + conn.cursor().execute(f"CREATE TABLE {created} (x TEXT)") + conn.cursor().execute(f"INSERT INTO {outer} VALUES (2)") + conn.rollback() + conn.close() + + count = count_rows(target, outer) + results.check( + "the rollback undid the rows around the DDL", count == 0, f"count is {count}" + ) + results.check( + "the rollback undid the DDL too", + not table_exists(target, created), + "the table created inside the transaction outlived it", + ) + finally: + with target.connect() as cleanup: + drop_table(cleanup, outer) + drop_table(cleanup, created) + + +def a_commit_preserves_an_open_cursor(target, results): + """`SQL_CURSOR_COMMIT_BEHAVIOR` is `SQL_CB_PRESERVE`, the opposite of the + Trino driver's `SQL_CB_CLOSE`. + + It is only true because `exec_direct` materialises every row before + returning, so no `rusqlite::Statement` is live when the commit runs. That is + why AGENTS.md calls eager materialisation load-bearing: making fetching lazy + would make this claim false without touching the code that states it.""" + table = unique_table("cursor") + with target.connect() as setup: + make_table(setup, table) + for i in range(3): + setup.cursor().execute(f"INSERT INTO {table} VALUES ({i})") + try: + conn = target.connect() + conn.autocommit = False + cursor = conn.cursor() + cursor.execute(f"SELECT id FROM {table} ORDER BY id") + first = cursor.fetchone() + results.check("the cursor produced a row before the commit", first is not None) + + conn.commit() + + try: + rest = cursor.fetchall() + except Exception as e: # noqa: BLE001 + results.bad( + "the cursor survives the commit", + f"fetching after the commit raised {type(e).__name__}: {e}", + ) + else: + results.check( + "the cursor still yields its remaining rows after the commit", + len(rest) == 2, + f"got {len(rest)} rows, expected 2", + ) + conn.autocommit = True + conn.close() + finally: + with target.connect() as cleanup: + drop_table(cleanup, table) + + +def autocommit_is_the_default(target, results): + """No explicit transaction, and the write is visible elsewhere with no + commit, which is what ODBC's default commit mode means.""" + table = unique_table("autocommit") + with target.connect() as setup: + make_table(setup, table) + try: + with target.connect() as conn: + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + count = count_rows(target, table) + results.check("an autocommit write needs no commit", count == 1, f"count is {count}") + finally: + with target.connect() as cleanup: + drop_table(cleanup, table) + + +def only_serializable_is_accepted(target, results): + """`SQL_TXN_ISOLATION_OPTION` advertises `SQL_TXN_SERIALIZABLE` alone, so + core accepts that one and rejects the rest with `HY024` before anything + reaches SQLite. + + Both halves matter. A driver that refused every level would pass the + rejection check while offering no isolation at all, and one that accepted + every level would store a value nothing applies: `SQL_ATTR_TXN_ISOLATION` is + kept on the connection and read back, never pushed to SQLite, so an + application asking for REPEATABLE READ would be told it had it while running + serializable.""" + conn = target.connect() + try: + try: + conn.set_attr(SQL_ATTR_TXN_ISOLATION, SQL_TXN_SERIALIZABLE) + except Exception as e: # noqa: BLE001 + results.bad( + "SQL_TXN_SERIALIZABLE is accepted", + f"the level SQLite implements was refused: {e}", + ) + else: + results.ok("SQL_TXN_SERIALIZABLE is accepted") + + for label, level in ( + ("READ UNCOMMITTED", SQL_TXN_READ_UNCOMMITTED), + ("READ COMMITTED", SQL_TXN_READ_COMMITTED), + ("REPEATABLE READ", SQL_TXN_REPEATABLE_READ), + ): + try: + conn.set_attr(SQL_ATTR_TXN_ISOLATION, level) + except Exception as e: # noqa: BLE001 + state = getattr(e, "args", ["", ""])[0] + results.check( + f"{label} is refused with HY024", state == "HY024", f"SQLSTATE {state}" + ) + else: + results.bad( + f"{label} is refused", + "it was accepted, but nothing applies a level SQLite does not have", + ) + finally: + conn.close() + + +def main(): + target = Target.from_argv( + sys.argv, + "usage: test_transactions.py " + '"Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db"', + ) + results = Results("transactions") + + for label, fn in ( + ("a rollback discards a write", a_rollback_discards_a_write), + ("a commit publishes a write", a_commit_publishes_a_write), + ("a commit spanning two tables is atomic", a_commit_spanning_two_tables_is_atomic), + ( + "a failed statement leaves the transaction usable", + a_failed_statement_leaves_the_transaction_usable, + ), + ("DDL inside a transaction is rolled back", ddl_inside_a_transaction_is_rolled_back), + ("a commit preserves an open cursor", a_commit_preserves_an_open_cursor), + ("autocommit is the default", autocommit_is_the_default), + ("only serializable is accepted", only_serializable_is_accepted), + ): + scenario(results, label, lambda fn=fn: fn(target, results)) + + sys.exit(results.summary()) + + +if __name__ == "__main__": + main() From ffdfeaf104aa3a0bd828e4c80a347dc4b7ab86e1 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 17:47:49 +0200 Subject: [PATCH 37/50] fix: type computed columns from their values, and add a type-transform fuzz `SELECT count(*)` was described as `SQL_WVARCHAR`. So was every other computed column: a literal, an expression, an aggregate, and even an explicit `CAST(x AS INTEGER)`. `sqlite3_column_decltype` names the column of a stored table or nothing at all, and `describe_column` fell back to `TEXT` whenever it answered nothing. Aggregates are among the most common shapes a BI tool sends, and a text column is one it will not offer to sum or chart, so this was visible in exactly the place the driver is meant to be useful. `infer_decl_type` supplies the missing declaration from the storage classes of the materialised values. It returns a *string* rather than a `SqlDataType`, so precision, scale and `SQL_DESC_TYPE_NAME` all come from the same functions that handle a real declaration and an inferred `INTEGER` column is indistinguishable from a declared one. Mixed classes resolve to whatever holds every value present: integers and reals to `REAL`, anything with text to `TEXT`. NULLs are skipped, because a NULL is the absence of a value rather than evidence of a type, and counting one would make the description depend on which rows matched. No rows, or nothing but NULL, keeps the old `TEXT` fallback. A declared type still wins over the values. SQLite lets any value into any column, so an `INTEGER` column can hold text; the declaration is what the schema promises and what the next row might hold. Both statement paths now collect rows before building descriptors. Values are converted using the descriptor's SQL type, so refining it afterwards would convert against the old one. Nothing is read twice: the rows are materialised either way. `test_type_matrix.py` was written first and failed on ten of these. It drives every (value, C type) pair through `SQLGetData` against invariants rather than a transcribed conversion matrix, then checks what `SQLDescribeCol` reports, which is the separate question an application asks first. 298 checks. The transactions suite loses the `as_int` workaround it carried for this, and its `count(*)` comparison against an integer now stands as a regression guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 29 ++ integration-tests/README.md | 9 + integration-tests/scripts/run-tests.sh | 3 + integration-tests/suites/odbc_abi.py | 15 + integration-tests/suites/test_transactions.py | 33 +- integration-tests/suites/test_type_matrix.py | 491 ++++++++++++++++++ src/backend/execute.rs | 251 +++++++-- 7 files changed, 779 insertions(+), 52 deletions(-) create mode 100644 integration-tests/suites/test_type_matrix.py diff --git a/AGENTS.md b/AGENTS.md index fde0889..d1223a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -428,6 +428,35 @@ session-wide gives every statement the most recent one. This driver applies it nowhere: `seconds` is ignored and core owns both the timer and the stored value, so two statements on one connection keep their own deadlines. +### Computed columns are typed from their values + +`sqlite3_column_decltype` names the column of a stored table or nothing at all, +so every computed column arrives with no declared type: a literal, an +expression, an aggregate, even an explicit `CAST(x AS INTEGER)`. Falling back to +`TEXT` describes `count(*)` as `SQL_WVARCHAR`, and a tool choosing a column to +sum or chart passes over it. + +`execute::infer_decl_type` supplies the missing declaration from the storage +classes of the materialised values, and `describe_column` uses it only when +SQLite offers none. Two consequences worth keeping straight: + +- **A declared type always wins.** SQLite lets any value into any column, so an + `INTEGER` column can hold text. The declaration is what the schema promises + and what the next row might hold, so inference must not reach a column that + has one. `a_declared_type_is_not_overridden_by_the_values` pins that. +- **Rows are collected before the descriptors are built.** Values are converted + using the descriptor's SQL type, so refining the type afterwards would convert + against the old one. Nothing is read twice; the rows are materialised anyway. + +The inference returns a declared-type *string* rather than a `SqlDataType`, so +precision, scale and `SQL_DESC_TYPE_NAME` all come from the same functions that +handle a real declaration and a column inferred as `INTEGER` is +indistinguishable from one declared that way. Mixed storage classes resolve to +whatever holds every value present: integers and reals to `REAL`, anything with +text to `TEXT`. NULLs are skipped, because a NULL is the absence of a value +rather than evidence of a type, and counting one would make the description +depend on which rows matched. + ### `row_count` has three answers, not two `StatementBackend::row_count` returns `Option<i64>`, and core reads all three diff --git a/integration-tests/README.md b/integration-tests/README.md index 5e84c36..ddfdf20 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -29,6 +29,7 @@ Both take `--help`. | `suites/test_integration.py` | The pyodbc suite, run once per connection style | | `suites/test_transactions.py` | Manual-commit transactions, run once per connection style | | `suites/test_c_abi.py` | The C ABI pen test, run once | +| `suites/test_type_matrix.py` | Type-transform fuzz and column description, run once | | `generated/` | Everything `setup.sh` writes. Gitignored | | `windows/` | The VM suite, its libvirt definitions, and [WINDOWS.md](windows/WINDOWS.md) | @@ -70,6 +71,14 @@ Because the spec's **(DM)** diagnostics come from the Driver Manager, that suite never demands one. Where a SQLSTATE is (DM)-annotated it asserts what the driver does instead, with a comment naming the diagnostic it is not asking for. +Last, `test_type_matrix.py`, also once and also through ctypes. It drives every +(value, C type) pair through `SQLGetData` and checks invariants rather than a +transcribed copy of the ODBC conversion matrix, which would mostly test the +transcription. It then checks what `SQLDescribeCol` *says* each column is, which +is a separate question from what `SQLGetData` will hand over: SQLite gives a +computed column no declared type, so the driver answers from the storage class +of the values, and a tool decides from that whether a column can be summed. + It then runs `cargo test`, so that one command gives a developer the whole suite. CI passes `--skip-cargo-test`, since its pre-commit job has already run exactly that via the `cargo-test` hook. diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh index 5944625..7fc600e 100755 --- a/integration-tests/scripts/run-tests.sh +++ b/integration-tests/scripts/run-tests.sh @@ -86,6 +86,9 @@ uv run --with pyodbc python3 "$SUITES_DIR/test_transactions.py" "DSN=$DSN_NAME" echo "=== Running raw C ABI pen test (no Driver Manager) ===" python3 "$SUITES_DIR/test_c_abi.py" "Driver=$DRIVER_PATH;Database=$DB_PATH" +echo "=== Running type-transform fuzz (no Driver Manager) ===" +python3 "$SUITES_DIR/test_type_matrix.py" "Driver=$DRIVER_PATH;Database=$DB_PATH" + # Run by default so that a developer invoking this script gets the whole suite # in one command. CI passes --skip-cargo-test, because its pre-commit job has # already run exactly this via the cargo-test hook, and repeating it there means diff --git a/integration-tests/suites/odbc_abi.py b/integration-tests/suites/odbc_abi.py index 0264989..2f4c355 100644 --- a/integration-tests/suites/odbc_abi.py +++ b/integration-tests/suites/odbc_abi.py @@ -71,6 +71,21 @@ def load(path): "SQLFetch": ([P], S), "SQLGetData": ([P, ctypes.c_uint16, S, P, L, ctypes.POINTER(L)], S), "SQLNumResultCols": ([P, ctypes.POINTER(S)], S), + # SQLULEN is 64-bit here, so ColumnSizePtr is a c_uint64 out-parameter. + "SQLDescribeColW": ( + [ + P, + ctypes.c_uint16, + W, + S, + ctypes.POINTER(S), + ctypes.POINTER(S), + ctypes.POINTER(ctypes.c_uint64), + ctypes.POINTER(S), + ctypes.POINTER(S), + ], + S, + ), "SQLRowCount": ([P, ctypes.POINTER(L)], S), "SQLCloseCursor": ([P], S), "SQLFreeStmt": ([P, ctypes.c_uint16], S), diff --git a/integration-tests/suites/test_transactions.py b/integration-tests/suites/test_transactions.py index 7ffdb78..5c78747 100644 --- a/integration-tests/suites/test_transactions.py +++ b/integration-tests/suites/test_transactions.py @@ -91,39 +91,28 @@ def drop_table(conn, table): pass -def as_int(value): - """An aggregate read back as a number, whatever the driver typed it as. - - `count(*)` arrives as a *string*: `sqlite3_column_decltype` is NULL for any - computed column, and `describe_column` falls back to `TEXT`, so every - expression is described as VARCHAR regardless of the storage class of the - value in it. That is a real finding, but it belongs to the type suite, not - to this one. Coercing here keeps a transaction failure from being reported - as a typing failure and the other way round.""" - return int(value) - - def count_rows(target, table): """Count from a *fresh* connection, which is what makes a commit or a rollback observable rather than merely reported. Only ever called once the writing transaction has ended. SQLite takes a write lock for the duration of one, and a second connection reading through - it would be answered `SQLITE_BUSY` rather than with a row count.""" + it would be answered `SQLITE_BUSY` rather than with a row count. + + The comparison against an integer is deliberate. `count(*)` is a computed + column, which SQLite gives no declared type, and the driver types it from + the storage class of the value; a regression there would hand back a string + and fail here. `test_type_matrix.py` is what tests that properly.""" with target.connect() as conn: - return as_int( - conn.cursor().execute(f"SELECT count(*) FROM {table}").fetchone()[0] - ) + return conn.cursor().execute(f"SELECT count(*) FROM {table}").fetchone()[0] def table_exists(target, table): with target.connect() as conn: return ( - as_int( - conn.cursor() - .execute("SELECT count(*) FROM sqlite_master WHERE name = ?", table) - .fetchone()[0] - ) + conn.cursor() + .execute("SELECT count(*) FROM sqlite_master WHERE name = ?", table) + .fetchone()[0] > 0 ) @@ -235,7 +224,7 @@ def a_failed_statement_leaves_the_transaction_usable(target, results): ) conn.autocommit = True - value = as_int(conn.cursor().execute("SELECT 1").fetchone()[0]) + value = conn.cursor().execute("SELECT 1").fetchone()[0] results.check("the connection still works", value == 1, f"got {value}") conn.close() finally: diff --git a/integration-tests/suites/test_type_matrix.py b/integration-tests/suites/test_type_matrix.py new file mode 100644 index 0000000..09f54d3 --- /dev/null +++ b/integration-tests/suites/test_type_matrix.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +""" +Type-transform fuzz for the SQLite ODBC driver. + +Two halves. + +The first drives every (SQLite value, C data type) pair through `SQLGetData` on +the raw C ABI and checks the outcome against invariants rather than against a +transcribed copy of the ODBC conversion matrix. Transcribing the matrix would +mostly test the transcription. The invariants below are the properties whose +violation is a defect, and they hold for every cell of it. + + 1. The call returns. No pair may crash, abort or hang the process. + 2. A failure carries a SQLSTATE. `SQL_ERROR` with no diagnostic record + leaves an application with an error it cannot interpret. + 3. NULL is reported as NULL. `SQL_NULL_DATA` in the indicator, for every + target type, whatever the source type is. + 4. A value that does not fit reports 22003, not a truncated number. + 5. Text that is not a number reports 22018, not a zero. + 6. A successful conversion round-trips. Where the value is checkable as + text, what comes back is what went in. + +The second checks what `SQLDescribeCol` *says* a column is, which is a separate +question from what `SQLGetData` will hand over. An application asks the first +before it asks the second, and a BI tool decides whether a column can be summed +or charted from the answer. SQLite has no declared type for a computed column, +so this is where the driver has to work from the storage class of the values it +already holds. + +Usage: + python3 integration-tests/suites/test_type_matrix.py \ + "Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db" + +Needs no server. Standard library only (ctypes, no pyodbc and no uv), and it +creates and drops its own fixture table, so it does not care which other suites +have run against the same database. +""" + +import ctypes +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import Results, Target # noqa: E402 +from odbc_abi import ( # noqa: E402 + SQL_ATTR_ODBC_VERSION, + SQL_DRIVER_NOPROMPT, + SQL_ERROR, + SQL_HANDLE_DBC, + SQL_HANDLE_ENV, + SQL_HANDLE_STMT, + SQL_NTS, + SQL_OV_ODBC3, + SQL_SUCCESS, + SQL_SUCCESS_WITH_INFO, + load, + sqlstate, + w, +) + +P = ctypes.c_void_p + +# C data types, from odbc_sys::CDataType. +C_CHAR = 1 +C_WCHAR = -8 +C_BIT = -7 +C_STINYINT = -26 +C_SSHORT = -15 +C_SLONG = -16 +C_SBIGINT = -25 +C_FLOAT = 7 +C_DOUBLE = 8 +C_BINARY = -2 +C_TYPE_DATE = 91 +C_TYPE_TIME = 92 +C_TYPE_TIMESTAMP = 93 + +C_TYPES = [ + ("SQL_C_CHAR", C_CHAR), + ("SQL_C_WCHAR", C_WCHAR), + ("SQL_C_BIT", C_BIT), + ("SQL_C_STINYINT", C_STINYINT), + ("SQL_C_SSHORT", C_SSHORT), + ("SQL_C_SLONG", C_SLONG), + ("SQL_C_SBIGINT", C_SBIGINT), + ("SQL_C_FLOAT", C_FLOAT), + ("SQL_C_DOUBLE", C_DOUBLE), + ("SQL_C_BINARY", C_BINARY), + ("SQL_C_TYPE_DATE", C_TYPE_DATE), + ("SQL_C_TYPE_TIME", C_TYPE_TIME), + ("SQL_C_TYPE_TIMESTAMP", C_TYPE_TIMESTAMP), +] + +# SQL types, from odbc_sys::SqlDataType, for the describe half. +SQL_BIGINT = -5 +SQL_DOUBLE = 8 +SQL_VARBINARY = -3 +SQL_WVARCHAR = -9 +SQL_BIT = -7 + +SQL_TYPE_NAMES = { + SQL_BIGINT: "SQL_BIGINT", + SQL_DOUBLE: "SQL_DOUBLE", + SQL_VARBINARY: "SQL_VARBINARY", + SQL_WVARCHAR: "SQL_WVARCHAR", + SQL_BIT: "SQL_BIT", + 4: "SQL_INTEGER", + 12: "SQL_VARCHAR", +} + +SQL_NULL_DATA = -1 + +# Spec SQLSTATEs this fuzz reasons about. +STATE_OUT_OF_RANGE = "22003" # Numeric value out of range +STATE_BAD_CAST = "22018" # Invalid character value for cast + +FIXTURE = "typem_fixture" + +# (label, SQLite expression, expected text when read as SQL_C_CHAR or None) +# +# Boundary values are the exact limits of SQLite's INTEGER, which is an i64: +# an off-by-one in a narrowing conversion shows up nowhere else. +VALUES = [ + ("integer zero", "0", "0"), + ("integer one", "1", "1"), + ("integer min", "-9223372036854775808", "-9223372036854775808"), + ("integer max", "9223372036854775807", "9223372036854775807"), + ("real", "1.5", None), + ("real negative", "-1.5", None), + ("real zero", "0.0", None), + ("text", "'hello'", "hello"), + ("text numeric", "'42'", "42"), + ("text empty", "''", ""), + ("text overflowing i64", "'99999999999999999999'", None), + ("text not a number", "'abc'", "abc"), + ("blob", "X'DEADBEEF'", None), + ("blob empty", "X''", None), + ("iso date", "'2020-02-03'", "2020-02-03"), + ("iso timestamp", "'2020-02-03 04:05:06'", "2020-02-03 04:05:06"), + ("expression sum", "1 + 1", "2"), + ("aggregate count", f"(SELECT count(*) FROM {FIXTURE})", "5"), +] + +# NULL must be reported as NULL for every target type. A driver that reports a +# NULL as 0 or "" corrupts data silently. +NULL_VALUES = [ + ("null literal", "NULL"), + ("null integer column", f"(SELECT n FROM {FIXTURE} WHERE id = 4)"), + ("null cast", "CAST(NULL AS INTEGER)"), +] + +# (label, expression, expected SQL type from SQLDescribeCol) +# +# SQLite reports no declared type for any of these, so the driver has to answer +# from the storage class of the value. `exec_direct` materialises every row +# before returning, so it holds them when the question is asked. +DESCRIBE_COMPUTED = [ + ("integer literal", "SELECT 1", SQL_BIGINT), + ("real literal", "SELECT 1.5", SQL_DOUBLE), + ("text literal", "SELECT 'x'", SQL_WVARCHAR), + ("blob literal", "SELECT X'00'", SQL_VARBINARY), + ("explicit integer cast", "SELECT CAST(1 AS INTEGER)", SQL_BIGINT), + ("explicit real cast", "SELECT CAST(1.5 AS REAL)", SQL_DOUBLE), + ("arithmetic on a column", f"SELECT id + 1 FROM {FIXTURE}", SQL_BIGINT), + ("count", f"SELECT count(*) FROM {FIXTURE}", SQL_BIGINT), + ("sum of integers", f"SELECT sum(id) FROM {FIXTURE}", SQL_BIGINT), + ("avg", f"SELECT avg(id) FROM {FIXTURE}", SQL_DOUBLE), + ("max of text", f"SELECT max(label) FROM {FIXTURE}", SQL_WVARCHAR), + ("length", f"SELECT length(label) FROM {FIXTURE}", SQL_BIGINT), +] + +# Declared columns already work. Asserted anyway, because the fix for the +# computed columns must not disturb them: a change that typed everything from +# the first row's storage class would break a declared INTEGER column holding a +# text value, which SQLite permits. +DESCRIBE_DECLARED = [ + ("declared INTEGER", f"SELECT id FROM {FIXTURE}", SQL_BIGINT), + ("declared REAL", f"SELECT amount FROM {FIXTURE}", SQL_DOUBLE), + ("declared TEXT", f"SELECT label FROM {FIXTURE}", SQL_WVARCHAR), + ("declared BLOB", f"SELECT payload FROM {FIXTURE}", SQL_VARBINARY), + ("declared BOOLEAN", f"SELECT flag FROM {FIXTURE}", SQL_BIT), + # SQLite lets any value into any column. The *declared* type still wins, + # because that is what the schema promises and what the next row might hold. + ( + "declared INTEGER holding text", + f"SELECT id FROM {FIXTURE} WHERE id = 5", + SQL_BIGINT, + ), +] + +R = Results("type matrix") +violations = [] + + +# These write R's counters directly rather than going through ok()/bad(): +# the matrix half prints per *violation*, not per check. 18 values against 13 C +# types is 234 PASS lines nobody reads. +def fail(kind, detail): + R.failed += 1 + violations.append(f"{kind}: {detail}") + + +def ok(): + R.passed += 1 + + +class Driver: + def __init__(self, so, conn_str): + self.lib = load(so) + self.env = P() + self.lib.SQLAllocHandle(SQL_HANDLE_ENV, None, ctypes.byref(self.env)) + self.lib.SQLSetEnvAttr(self.env, SQL_ATTR_ODBC_VERSION, P(SQL_OV_ODBC3), 0) + self.dbc = P() + self.lib.SQLAllocHandle(SQL_HANDLE_DBC, self.env, ctypes.byref(self.dbc)) + cs, self._keep = w(conn_str) + ob = (ctypes.c_uint16 * 1024)() + ol = ctypes.c_int16(0) + r = self.lib.SQLDriverConnectW( + self.dbc, + None, + cs, + SQL_NTS, + ctypes.cast(ob, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(ol), + SQL_DRIVER_NOPROMPT, + ) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + raise SystemExit(f"could not connect: {sqlstate(self.lib, SQL_HANDLE_DBC, self.dbc)}") + + def exec_sql(self, sql): + """Run a statement for its effect. Returns (ret, sqlstate).""" + stmt = P() + self.lib.SQLAllocHandle(SQL_HANDLE_STMT, self.dbc, ctypes.byref(stmt)) + try: + text, _k = w(sql) + r = self.lib.SQLExecDirectW(stmt, text, SQL_NTS) + return (r, sqlstate(self.lib, SQL_HANDLE_STMT, stmt)) + finally: + self.lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + def fetch_as(self, expr, c_type): + """Run `SELECT <expr>` and read column 1 as `c_type`. + + Returns (ret, sqlstate, indicator, raw_bytes). + """ + lib = self.lib + stmt = P() + lib.SQLAllocHandle(SQL_HANDLE_STMT, self.dbc, ctypes.byref(stmt)) + try: + sql, _k = w(f"SELECT {expr}") + r = lib.SQLExecDirectW(stmt, sql, SQL_NTS) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return ("EXEC", sqlstate(lib, SQL_HANDLE_STMT, stmt), None, None) + r = lib.SQLFetch(stmt) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return ("FETCH", sqlstate(lib, SQL_HANDLE_STMT, stmt), None, None) + buf = ctypes.create_string_buffer(512) + ind = ctypes.c_int64(0) + r = lib.SQLGetData( + stmt, 1, c_type, ctypes.cast(buf, P), 512, ctypes.byref(ind) + ) + return (r, sqlstate(lib, SQL_HANDLE_STMT, stmt), ind.value, buf.raw) + finally: + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + def describe(self, sql): + """Run `sql` and return column 1's (sql_type, column_size), or None.""" + lib = self.lib + stmt = P() + lib.SQLAllocHandle(SQL_HANDLE_STMT, self.dbc, ctypes.byref(stmt)) + try: + text, _k = w(sql) + r = lib.SQLExecDirectW(stmt, text, SQL_NTS) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return None + name = (ctypes.c_uint16 * 128)() + namelen = ctypes.c_int16(0) + data_type = ctypes.c_int16(0) + size = ctypes.c_uint64(0) + digits = ctypes.c_int16(0) + nullable = ctypes.c_int16(0) + r = lib.SQLDescribeColW( + stmt, + 1, + ctypes.cast(name, ctypes.POINTER(ctypes.c_uint16)), + 128, + ctypes.byref(namelen), + ctypes.byref(data_type), + ctypes.byref(size), + ctypes.byref(digits), + ctypes.byref(nullable), + ) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return None + return (data_type.value, size.value) + finally: + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + def close(self): + self.lib.SQLDisconnect(self.dbc) + self.lib.SQLFreeHandle(SQL_HANDLE_DBC, self.dbc) + self.lib.SQLFreeHandle(SQL_HANDLE_ENV, self.env) + + +def type_name(code): + return SQL_TYPE_NAMES.get(code, str(code)) + + +def as_text(raw, c_type): + if c_type == C_WCHAR: + u = ctypes.cast(raw, ctypes.POINTER(ctypes.c_uint16)) + out = [] + for i in range(256): + if u[i] == 0: + break + out.append(chr(u[i])) + return "".join(out) + return raw.split(b"\x00", 1)[0].decode("utf-8", "replace") + + +def make_fixture(d): + """A table of this suite's own. + + `test_integration.py` drops `types_test` when it finishes, including the + copy `create_test_db.sql` made, so a suite that leaned on the shared + fixtures would pass or fail depending on what ran before it. + """ + d.exec_sql(f"DROP TABLE IF EXISTS {FIXTURE}") + d.exec_sql( + f"CREATE TABLE {FIXTURE} (" + " id INTEGER PRIMARY KEY," + " amount REAL," + " label TEXT," + " payload BLOB," + " flag BOOLEAN," + " n INTEGER" + ")" + ) + d.exec_sql(f"INSERT INTO {FIXTURE} VALUES (1, 1.5, 'alpha', X'DEADBEEF', 1, 10)") + d.exec_sql(f"INSERT INTO {FIXTURE} VALUES (2, 2.5, 'beta', X'00', 0, 20)") + d.exec_sql(f"INSERT INTO {FIXTURE} VALUES (3, 3.5, 'gamma', NULL, 1, 30)") + d.exec_sql(f"INSERT INTO {FIXTURE} VALUES (4, NULL, NULL, NULL, NULL, NULL)") + # SQLite lets any value into any column, so this row puts text in a column + # declared INTEGER. The declared type must still win when describing it. + d.exec_sql(f"INSERT INTO {FIXTURE} VALUES (5, 5.5, 'delta', NULL, 1, 50)") + + +def drop_fixture(d): + d.exec_sql(f"DROP TABLE IF EXISTS {FIXTURE}") + + +def main(): + target = Target.from_argv( + sys.argv, + "usage: test_type_matrix.py " + '"Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db"', + ) + so = target.require_driver_path() + + d = Driver(so, target.conn_str()) + print("=== type-transform fuzz ===\n") + make_fixture(d) + + try: + # -- invariants 1, 2, 4, 5 and 6 over the full matrix ------------- + print(f"--- {len(VALUES)} values x {len(C_TYPES)} C types ---") + for label, expr, want_text in VALUES: + for cname, ctype in C_TYPES: + ret, state, ind, raw = d.fetch_as(expr, ctype) + cell = f"{label} -> {cname}" + + if ret in ("EXEC", "FETCH"): + fail("query failed", f"{cell}: {ret} {state}") + continue + + # Invariant 2: a failure must carry a SQLSTATE. + if ret == SQL_ERROR and not state: + fail("error with no SQLSTATE", cell) + continue + + if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + # Invariant 6: a successful text conversion round-trips. + if want_text is not None and ctype in (C_CHAR, C_WCHAR): + got = as_text(raw, ctype) + if got != want_text: + fail( + "round-trip mismatch", + f"{cell}: got {got!r}, expected {want_text!r}", + ) + continue + ok() + + # -- invariant 3: NULL is NULL, for every target type ------------- + print(f"--- {len(NULL_VALUES)} NULLs x {len(C_TYPES)} C types ---") + for label, expr in NULL_VALUES: + for cname, ctype in C_TYPES: + ret, state, ind, _raw = d.fetch_as(expr, ctype) + cell = f"{label} -> {cname}" + if ret in ("EXEC", "FETCH"): + fail("query failed", f"{cell}: {ret} {state}") + continue + if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + if ind != SQL_NULL_DATA: + fail( + "NULL not reported as NULL", + f"{cell}: indicator {ind}, expected {SQL_NULL_DATA}", + ) + continue + elif ret == SQL_ERROR and not state: + fail("error with no SQLSTATE", cell) + continue + ok() + + # -- invariant 4: an overflowing value says so -------------------- + print("--- overflow and bad-cast SQLSTATEs ---") + for label, expr, ctype, cname, want_state in ( + ("i64 max into SQL_C_SSHORT", "9223372036854775807", C_SSHORT, "SQL_C_SSHORT", STATE_OUT_OF_RANGE), + ("i64 max into SQL_C_SLONG", "9223372036854775807", C_SLONG, "SQL_C_SLONG", STATE_OUT_OF_RANGE), + ("text overflowing i64", "'99999999999999999999'", C_SBIGINT, "SQL_C_SBIGINT", STATE_OUT_OF_RANGE), + ("non-numeric text as integer", "'abc'", C_SBIGINT, "SQL_C_SBIGINT", STATE_BAD_CAST), + ("non-numeric text as double", "'abc'", C_DOUBLE, "SQL_C_DOUBLE", STATE_BAD_CAST), + ): + ret, state, _ind, _raw = d.fetch_as(expr, ctype) + R.check( + f"{label} reports {want_state}", + ret == SQL_ERROR and state == want_state, + f"got {ret} / {state or '<none>'}", + ) + + # -- SQLDescribeCol on declared columns --------------------------- + print("\n--- SQLDescribeCol: declared columns ---") + for label, sql, want in DESCRIBE_DECLARED: + got = d.describe(sql) + if got is None: + R.bad(f"describe {label}", "the statement did not execute") + continue + R.check( + f"describe {label} is {type_name(want)}", + got[0] == want, + f"got {type_name(got[0])}", + ) + + # -- SQLDescribeCol on computed columns --------------------------- + print("\n--- SQLDescribeCol: computed columns ---") + # SQLite has no declared type for an expression, so the driver has to + # answer from the storage class of the values it materialised. Reporting + # everything as WVARCHAR tells a BI tool that `count(*)` is text, which + # is not a column it will offer to sum or chart. + for label, sql, want in DESCRIBE_COMPUTED: + got = d.describe(sql) + if got is None: + R.bad(f"describe {label}", "the statement did not execute") + continue + R.check( + f"describe {label} is {type_name(want)}", + got[0] == want, + f"got {type_name(got[0])}", + ) + + # A computed column with no rows has nothing to infer from, so the + # fallback stands. What must not happen is a crash or a nonsense type. + print("\n--- SQLDescribeCol: nothing to infer from ---") + got = d.describe(f"SELECT id + 1 FROM {FIXTURE} WHERE 0") + R.check( + "a computed column over zero rows still describes", + got is not None and got[0] in (SQL_WVARCHAR, 12), + f"got {type_name(got[0]) if got else 'nothing'}", + ) + got = d.describe("SELECT NULL") + R.check( + "an all-NULL computed column still describes", + got is not None and got[0] in (SQL_WVARCHAR, 12), + f"got {type_name(got[0]) if got else 'nothing'}", + ) + finally: + drop_fixture(d) + d.close() + + if violations: + print(f"\n--- {len(violations)} violations ---") + for v in violations[:40]: + print(f"FAIL {v}") + if len(violations) > 40: + print(f"... and {len(violations) - 40} more") + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/backend/execute.rs b/src/backend/execute.rs index 2534406..d4ec102 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -37,12 +37,18 @@ fn describe_column( stmt: &rusqlite::Statement<'_>, i: usize, col: &rusqlite::Column<'_>, + inferred_decl: &str, ) -> ColumnDescriptor { let name = stmt .column_name(i) .map(|n| n.to_string()) .unwrap_or_else(|_| "?".to_string()); - let decl = col.decl_type().unwrap_or("TEXT").to_string(); + // The declared type when SQLite has one, and otherwise whatever the + // materialised values imply. `sqlite3_column_decltype` is NULL for every + // computed column, so without the fallback a `count(*)` would be described + // as `TEXT`, and an application choosing a column to sum or chart would + // pass over it. See [`infer_decl_type`]. + let decl = col.decl_type().unwrap_or(inferred_decl).to_string(); let sql_type = sqlite_type_to_sql_data_type(&decl); let descriptor = ColumnDescriptor::new(name, sql_type) @@ -151,6 +157,59 @@ fn is_searched_dml(sql: &str) -> bool { .any(|dml| keyword.eq_ignore_ascii_case(dml)) } +/// A declared-type string for column `i`, worked out from the values in it. +/// +/// Only consulted for a column SQLite reports no declared type for, which is +/// every computed one: a literal, an expression, an aggregate, even an explicit +/// `CAST(x AS INTEGER)`. `sqlite3_column_decltype` names the column of a stored +/// table or nothing at all, so without this a `count(*)` is described as `TEXT` +/// and a tool looking for something to sum passes over it. +/// +/// A *string* rather than a `SqlDataType` so that the declared-type path stays +/// the only one: precision, scale and `SQL_DESC_TYPE_NAME` are all derived from +/// it by the same functions that handle a real declaration, and a column +/// inferred as `INTEGER` is therefore indistinguishable from one declared that +/// way. +/// +/// SQLite lets the storage class vary from row to row, so the answer has to +/// cover every value present: +/// +/// - Only integers is `INTEGER`; only reals is `REAL`. +/// - Integers and reals together is `REAL`, the one that can hold both. +/// - Only blobs is `BLOB`. +/// - Anything else mixed, or any text, is `TEXT`, which every storage class +/// converts into. +/// - No rows at all, or nothing but NULL, leaves `TEXT`. There is no evidence +/// to work from, and it is what the column was described as before this +/// existed. +/// +/// NULLs are skipped rather than counted: a NULL is absence of a value, not +/// evidence of a type, and letting one row of NULL widen an integer column to +/// text would make the description depend on which rows happened to match. +fn infer_decl_type(rows: &[Vec<rusqlite::types::Value>], i: usize) -> String { + use rusqlite::types::Value; + + let (mut ints, mut reals, mut blobs, mut others) = (false, false, false, false); + for row in rows { + match row.get(i) { + Some(Value::Integer(_)) => ints = true, + Some(Value::Real(_)) => reals = true, + Some(Value::Blob(_)) => blobs = true, + Some(Value::Null) | None => {} + Some(Value::Text(_)) => others = true, + } + } + + match (ints, reals, blobs, others) { + (true, false, false, false) => "INTEGER", + (false, true, false, false) => "REAL", + (true, true, false, false) => "REAL", + (false, false, true, false) => "BLOB", + _ => "TEXT", + } + .to_string() +} + pub(super) fn exec_direct( conn: &SqliteConnection, sql: &str, @@ -171,27 +230,45 @@ pub(super) fn exec_direct( )); } - // SELECT path: collect column metadata, then eagerly fetch all rows. + // SELECT path. The rows are collected before the descriptors are built, + // because a computed column has no declared type and the values are the + // only evidence of what it holds. Nothing is read twice: the rows have to + // be materialised anyway (see the eager-materialisation section of + // AGENTS.md), so this only defers the conversion until the target type is + // settled. + let col_count = stmt.column_count(); + let mut raw: Vec<Vec<rusqlite::types::Value>> = Vec::new(); + { + let mut raw_rows = stmt.query([]).map_err(map_sqlite_error)?; + while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { + let mut row_values = Vec::with_capacity(col_count); + for i in 0..col_count { + row_values.push( + row.get::<_, rusqlite::types::Value>(i) + .map_err(map_sqlite_error)?, + ); + } + raw.push(row_values); + } + } + // Fully-qualified call to avoid name collision with Backend::columns. let sqlite_columns = rusqlite::Statement::columns(&stmt); let columns: Vec<ColumnDescriptor> = sqlite_columns .iter() .enumerate() - .map(|(i, col)| describe_column(&stmt, i, col)) + .map(|(i, col)| describe_column(&stmt, i, col, &infer_decl_type(&raw, i))) .collect(); - // Eagerly fetch all rows - let col_count = stmt.column_count(); - let mut rows = Vec::new(); - let mut raw_rows = stmt.query([]).map_err(map_sqlite_error)?; - while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { - let mut row_values = Vec::with_capacity(col_count); - for (i, col) in columns.iter().enumerate() { - let value: rusqlite::types::Value = row.get(i).map_err(map_sqlite_error)?; - row_values.push(sqlite_value_to_column_value(value, col.sql_type())); - } - rows.push(row_values); - } + let rows = raw + .into_iter() + .map(|row| { + row.into_iter() + .zip(columns.iter()) + .map(|(value, col)| sqlite_value_to_column_value(value, col.sql_type())) + .collect() + }) + .collect(); Ok(SqliteStatement::new(columns, rows)) } @@ -257,27 +334,43 @@ pub(super) fn execute( return Ok(ExecuteOutcome::default()); } - // SELECT path + // SELECT path. Rows first, then descriptors, for the reason `exec_direct` + // gives: a computed column has no declared type, and the values are the + // only evidence of what it holds. + let col_count = prepared.column_count(); + let mut raw: Vec<Vec<rusqlite::types::Value>> = Vec::new(); + { + let mut raw_rows = prepared + .query(rusqlite::params_from_iter(rusqlite_params)) + .map_err(map_sqlite_error)?; + while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { + let mut row_values = Vec::with_capacity(col_count); + for i in 0..col_count { + row_values.push( + row.get::<_, rusqlite::types::Value>(i) + .map_err(map_sqlite_error)?, + ); + } + raw.push(row_values); + } + } + let sqlite_columns = rusqlite::Statement::columns(&prepared); let columns: Vec<ColumnDescriptor> = sqlite_columns .iter() .enumerate() - .map(|(i, col)| describe_column(&prepared, i, col)) + .map(|(i, col)| describe_column(&prepared, i, col, &infer_decl_type(&raw, i))) .collect(); - let col_count = prepared.column_count(); - let mut rows = Vec::new(); - let mut raw_rows = prepared - .query(rusqlite::params_from_iter(rusqlite_params)) - .map_err(map_sqlite_error)?; - while let Some(row) = raw_rows.next().map_err(map_sqlite_error)? { - let mut row_values = Vec::with_capacity(col_count); - for (i, col) in columns.iter().enumerate() { - let value: rusqlite::types::Value = row.get(i).map_err(map_sqlite_error)?; - row_values.push(sqlite_value_to_column_value(value, col.sql_type())); - } - rows.push(row_values); - } + let rows = raw + .into_iter() + .map(|row| { + row.into_iter() + .zip(columns.iter()) + .map(|(value, col)| sqlite_value_to_column_value(value, col.sql_type())) + .collect() + }) + .collect(); stmt.columns = columns; stmt.rows = rows; @@ -405,6 +498,104 @@ mod tests { use super::*; use crate::backend::{SqliteConnection, SqliteStatement}; + /// One column's worth of values, as `infer_decl_type` takes them. + fn column_of(values: Vec<rusqlite::types::Value>) -> Vec<Vec<rusqlite::types::Value>> { + values.into_iter().map(|v| vec![v]).collect() + } + + /// Every storage-class combination `infer_decl_type` distinguishes. + /// + /// The mixed cases are the ones worth pinning: SQLite lets the class vary + /// per row, so the answer has to hold every value the column actually + /// contains rather than describe only the first. + #[test] + fn a_computed_column_is_typed_from_the_values_in_it() { + use rusqlite::types::Value; + + for (label, values, want) in [ + ( + "all integers", + vec![Value::Integer(1), Value::Integer(2)], + "INTEGER", + ), + ("all reals", vec![Value::Real(1.5)], "REAL"), + ("all text", vec![Value::Text("a".into())], "TEXT"), + ("all blobs", vec![Value::Blob(vec![0])], "BLOB"), + // REAL holds both, so it is the answer that loses nothing. + ( + "integers and reals", + vec![Value::Integer(1), Value::Real(1.5)], + "REAL", + ), + // Text converts from every class, so it is the only safe answer + // once one is present. + ( + "integers and text", + vec![Value::Integer(1), Value::Text("a".into())], + "TEXT", + ), + ( + "blobs and integers", + vec![Value::Blob(vec![0]), Value::Integer(1)], + "TEXT", + ), + // NULL is absence of a value, not evidence of a type. Counting it + // would make the description depend on which rows matched. + ( + "integers with a NULL among them", + vec![Value::Integer(1), Value::Null, Value::Integer(2)], + "INTEGER", + ), + // No evidence at all leaves the pre-existing fallback. + ("nothing but NULL", vec![Value::Null], "TEXT"), + ("no rows", vec![], "TEXT"), + ] { + assert_eq!( + infer_decl_type(&column_of(values), 0), + want, + "{label} should infer {want}" + ); + } + } + + /// A declared type always wins, even over values that contradict it. + /// + /// SQLite lets any value into any column, so an `INTEGER` column can hold + /// text. The schema is what the next row might hold, and what the + /// application asked about, so inference must not reach a column that has a + /// declaration. + #[test] + fn a_declared_type_is_not_overridden_by_the_values() { + let conn = conn_with( + "CREATE TABLE t (n INTEGER); + INSERT INTO t VALUES ('not a number');", + ); + let stmt = exec_direct(&conn, "SELECT n FROM t").unwrap(); + let col = stmt.describe_col(1).unwrap(); + assert_eq!( + col.sql_type(), + sqlite_type_to_sql_data_type("INTEGER"), + "the declared INTEGER must survive a text value in the column" + ); + } + + /// The headline case: `count(*)` is a number, not text. + #[test] + fn an_aggregate_is_described_as_a_number() { + let conn = conn_with("CREATE TABLE t (n INTEGER); INSERT INTO t VALUES (1), (2);"); + let stmt = exec_direct(&conn, "SELECT count(*) FROM t").unwrap(); + assert_eq!( + stmt.describe_col(1).unwrap().sql_type(), + sqlite_type_to_sql_data_type("INTEGER"), + ); + + let stmt = exec_direct(&conn, "SELECT avg(n) FROM t").unwrap(); + assert_eq!( + stmt.describe_col(1).unwrap().sql_type(), + sqlite_type_to_sql_data_type("REAL"), + ); + } + fn conn_with(schema: &str) -> SqliteConnection { let c = rusqlite::Connection::open_in_memory().unwrap(); c.execute_batch(schema).unwrap(); From 7c824ded90f32731e1d0da07b58a01b898713c26 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 17:53:19 +0200 Subject: [PATCH 38/50] test: add a SQL surface suite, covering the ODBC escapes for the first time Walks the SQL a BI tool emits through the Driver Manager: joins of every shape, aggregates, window functions, subqueries and CTEs, set operations, and parameters in every clause that takes one. 80 checks, run once per connection style. Two things it covers that the Trino driver's equivalent cannot. `escape_dialect.rs` had no integration coverage at all. The suite drives `{fn ...}`, `{d ...}`, `{t ...}`, `{ts ...}` and `{oj ...}`, including the three date/time forms that are bare keywords in SQLite: `SELECT CURRENT_DATE();` is a syntax error, so a name swap cannot express them and `rewrite_scalar_fn` replaces the whole escape instead. That path is now exercised end to end. And SQLite actually publishes keys and indexes, where Trino publishes none. The fixture carries a primary key, a foreign key with a named target column and an index, so `SQLPrimaryKeys`, `SQLForeignKeys`, `SQLStatistics` and `SQLSpecialColumns` are asserted on real rows rather than on an empty set that did not error. The foreign-key check pins `PKCOLUMN_NAME`, which the spec marks not-NULL. Two probes assert a refusal rather than a result, for the capabilities the driver deliberately does not claim: `GROUP BY GROUPING SETS`, absent from `SQL_GROUP_BY`, and `> ALL`, absent from both `SQL_SUBQUERIES` and `SQL_SQL92_PREDICATES`. Each names the token SQLite must complain about, so a statement failing for an unrelated reason cannot pass them, and a control query of the same CTE shape confirms the syntax around them is sound. The inline relations are CTEs with column lists rather than Trino's `(VALUES ...) AS t(x)`, which SQLite has no syntax for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- integration-tests/README.md | 10 + integration-tests/scripts/run-tests.sh | 7 + integration-tests/suites/test_sql_surface.py | 424 +++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 integration-tests/suites/test_sql_surface.py diff --git a/integration-tests/README.md b/integration-tests/README.md index ddfdf20..7c9b448 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -28,6 +28,7 @@ Both take `--help`. | `suites/odbc_abi.py` | The raw ODBC C ABI declared for `ctypes`, for the suites that skip the Driver Manager | | `suites/test_integration.py` | The pyodbc suite, run once per connection style | | `suites/test_transactions.py` | Manual-commit transactions, run once per connection style | +| `suites/test_sql_surface.py` | The SQL a BI tool emits, the ODBC escapes and the catalog functions, run once per connection style | | `suites/test_c_abi.py` | The C ABI pen test, run once | | `suites/test_type_matrix.py` | Type-transform fuzz and column description, run once | | `generated/` | Everything `setup.sh` writes. Gitignored | @@ -58,6 +59,15 @@ a failed statement leaves a SQLite transaction usable rather than aborting it, a commit preserves an open cursor rather than closing it, and serializable is the level that must be accepted rather than refused. +`test_sql_surface.py` runs both ways too. It walks joins, aggregates, window +functions, CTEs, set operations and parameters, and is the only suite that +reaches `escape_dialect.rs`: the `{fn ...}`, `{d ...}`, `{ts ...}` and +`{oj ...}` sequences, including the three date/time forms that are bare +keywords in SQLite and need the whole escape rewritten rather than the name +swapped. Where the Trino driver can only check that a key or index lookup +returns nothing without erroring, this one asserts the rows, because SQLite +publishes all three. + Then `test_c_abi.py`, once. It loads the driver's `.so` with `ctypes` and calls the exported entry points with **no Driver Manager in the loop**, which is the point: unixODBC answers a large part of the ODBC state machine itself, so what diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh index 7fc600e..d5c417f 100755 --- a/integration-tests/scripts/run-tests.sh +++ b/integration-tests/scripts/run-tests.sh @@ -80,6 +80,13 @@ uv run --with pyodbc python3 "$SUITES_DIR/test_transactions.py" \ echo "=== Running transaction tests (DSN) ===" uv run --with pyodbc python3 "$SUITES_DIR/test_transactions.py" "DSN=$DSN_NAME" +echo "=== Running SQL surface tests (DSN-less) ===" +uv run --with pyodbc python3 "$SUITES_DIR/test_sql_surface.py" \ + "Driver=$DRIVER_PATH;Database=$DB_PATH" + +echo "=== Running SQL surface tests (DSN) ===" +uv run --with pyodbc python3 "$SUITES_DIR/test_sql_surface.py" "DSN=$DSN_NAME" + # Once, not per connection style: this suite loads the .so with ctypes and # never reaches a Driver Manager, so a DSN run would exercise the same code by # a longer route. Plain python3, because it needs no third-party package. diff --git a/integration-tests/suites/test_sql_surface.py b/integration-tests/suites/test_sql_surface.py new file mode 100644 index 0000000..7502e57 --- /dev/null +++ b/integration-tests/suites/test_sql_surface.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +""" +SQL surface pen test for the SQLite ODBC driver. + +Walks the SQL a BI tool emits and checks the driver carries it through intact: +joins of every shape, aggregates, window functions, subqueries, CTEs, set +operations, parameters in every clause that accepts one, the ODBC escape +sequences, and the ODBC catalog functions. + +Where a query has one right answer it is asserted. Where it does not (a plan +listing), the assertion is that it returns a result of the expected shape, which +is still enough to catch a translation or fetch failure. + +Two things this covers that the Trino driver's equivalent cannot: + + - **The ODBC escape sequences.** `{fn ...}`, `{d ...}`, `{ts ...}` and + `{oj ...}` are translated by `escape_dialect.rs` into what SQLite spells + them as, and three of them (`CURRENT_DATE`, `CURRENT_TIME`, + `CURRENT_TIMESTAMP`) are bare keywords that a name swap alone cannot + produce. Nothing else in the suite exercises that module. + - **Keys and indexes that are really there.** Trino publishes no primary key, + foreign key or index metadata, so its suite can only assert that those + calls return an empty set without erroring. SQLite has all three, so the + fixture here carries them and the assertions are on real rows. + +Usage: + python3 integration-tests/suites/test_sql_surface.py \ + "Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db" + python3 integration-tests/suites/test_sql_surface.py "DSN=test_sqlite" + +Needs no server. Requires `pyodbc`, normally through `uv run --with pyodbc`. +Creates and drops its own fixture tables, so it does not care what else has run +against the same database. +""" + +import os +import sys + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Target # noqa: E402 + +R = Results("sql surface") + +# A query that hangs is worse than one that errors: it takes the whole suite +# with it and gives no diagnosis. Nothing here should come close. +QUERY_TIMEOUT_SECONDS = 60 + +PARENT = "sqlsurf_parent" +CHILD = "sqlsurf_child" + + +def make_fixture(cur): + """Two related tables, so the key and index calls have something to find. + + Dropped in reverse order: `sqlsurf_child` holds the foreign key, and the + driver turns foreign-key enforcement on for every connection, so dropping + the parent first would be refused. + """ + cur.execute(f"DROP TABLE IF EXISTS {CHILD}") + cur.execute(f"DROP TABLE IF EXISTS {PARENT}") + cur.execute(f"CREATE TABLE {PARENT} (id INTEGER PRIMARY KEY, name TEXT NOT NULL)") + cur.execute( + f"CREATE TABLE {CHILD} (" + " id INTEGER PRIMARY KEY," + f" parent_id INTEGER REFERENCES {PARENT}(id)," + " label TEXT" + ")" + ) + cur.execute(f"CREATE INDEX {CHILD}_label_idx ON {CHILD}(label)") + cur.execute(f"INSERT INTO {PARENT} VALUES (1, 'alpha'), (2, 'beta')") + cur.execute(f"INSERT INTO {CHILD} VALUES (1, 1, 'x'), (2, 1, 'y'), (3, 2, 'z')") + + +def drop_fixture(cur): + try: + cur.execute(f"DROP TABLE IF EXISTS {CHILD}") + cur.execute(f"DROP TABLE IF EXISTS {PARENT}") + except Exception: # noqa: BLE001 + pass + + +def main(): + target = Target.from_argv( + sys.argv, + "usage: test_sql_surface.py " + '"Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db"', + ) + conn = pyodbc.connect(target.conn_str(), autocommit=True) + conn.timeout = QUERY_TIMEOUT_SECONDS + cur = conn.cursor() + + def scalar(sql, want, params=None): + got = ( + cur.execute(sql, params).fetchone()[0] + if params + else cur.execute(sql).fetchone()[0] + ) + assert got == want, f"expected {want!r}, got {got!r}" + + def rows(sql, want_count=None, min_count=None, params=None): + got = (cur.execute(sql, params) if params else cur.execute(sql)).fetchall() + if want_count is not None: + assert len(got) == want_count, f"expected {want_count} rows, got {len(got)}" + if min_count is not None: + assert len(got) >= min_count, f"expected >= {min_count} rows, got {len(got)}" + return got + + def shape(sql, min_cols=1, min_rows=1): + """Executes and returns rows; asserts only the result's shape.""" + cur.execute(sql) + assert cur.description is not None, "no result set" + assert len(cur.description) >= min_cols, f"expected >= {min_cols} columns" + got = cur.fetchall() + assert len(got) >= min_rows, f"expected >= {min_rows} rows, got {len(got)}" + return got + + def refused(sql, near): + """SQLite must reject `sql`, and the driver must say so rather than + succeeding with something invented. + + `near` is the token SQLite should be complaining about. Without it this + would pass for a statement that failed for some unrelated reason, such + as a typo in the surrounding CTE, and a probe that cannot fail is worth + nothing. + """ + try: + cur.execute(sql).fetchall() + except pyodbc.Error as e: + message = str(e) + assert near in message, ( + f"refused, but not for the expected reason: wanted a complaint " + f"about {near!r}, got {message!r}" + ) + return + raise AssertionError("the statement was accepted, but SQLite has no such syntax") + + make_fixture(cur) + try: + # -------------------------------------------------------------- + # SQLite has no `(VALUES ...) AS t(x)` aliasing, so the inline + # relations below are CTEs, which do take a column list. + print("--- joins ---") + R.run("inner join", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2),(3)), b(y) AS (VALUES (2),(3),(4)) " + "SELECT count(*) FROM a JOIN b ON a.x = b.y", 2)) + R.run("left outer join", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2),(3)), b(y) AS (VALUES (2)) " + "SELECT count(*) FROM a LEFT JOIN b ON a.x = b.y", 3)) + # RIGHT and FULL arrived in SQLite 3.39.0. The bundled library is + # newer, and SQL_OUTER_JOIN_CAPABILITIES claims both. + R.run("right outer join", lambda: scalar( + "WITH a(x) AS (VALUES (1)), b(y) AS (VALUES (1),(2),(3)) " + "SELECT count(*) FROM a RIGHT JOIN b ON a.x = b.y", 3)) + R.run("full outer join", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2)), b(y) AS (VALUES (2),(3)) " + "SELECT count(*) FROM a FULL JOIN b ON a.x = b.y", 3)) + R.run("cross join", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2),(3)), b(y) AS (VALUES (1),(2)) " + "SELECT count(*) FROM a CROSS JOIN b", 6)) + R.run("non-equi join", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2),(3)), b(y) AS (VALUES (1),(2),(3)) " + "SELECT count(*) FROM a JOIN b ON a.x < b.y", 3)) + R.run("three-way join", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2)), b(y) AS (VALUES (1),(2)), " + "c(z) AS (VALUES (1),(2)) " + "SELECT count(*) FROM a JOIN b ON a.x=b.y JOIN c ON b.y=c.z", 2)) + R.run("join across the fixture tables", lambda: scalar( + f"SELECT count(*) FROM {PARENT} p JOIN {CHILD} c ON c.parent_id = p.id", 3)) + + # -------------------------------------------------------------- + print("\n--- aggregates and GROUP BY ---") + R.run("count/sum/min/max", lambda: scalar( + "WITH t(x) AS (VALUES (1),(2),(3)) " + "SELECT count(*) + sum(x) + min(x) + max(x) FROM t", 3 + 6 + 1 + 3)) + R.run("count(DISTINCT)", lambda: scalar( + "WITH t(x) AS (VALUES (1),(1),(2)) SELECT count(DISTINCT x) FROM t", 2)) + R.run("avg is a real", lambda: scalar( + "WITH t(x) AS (VALUES (1),(2)) SELECT avg(x) FROM t", 1.5)) + R.run("GROUP BY", lambda: rows( + "WITH t(x) AS (VALUES (1),(1),(2)) SELECT x, count(*) FROM t GROUP BY x", + want_count=2)) + R.run("HAVING", lambda: rows( + "WITH t(x) AS (VALUES (1),(1),(2)) " + "SELECT x FROM t GROUP BY x HAVING count(*) > 1", want_count=1)) + R.run("group_concat", lambda: scalar( + "WITH t(x) AS (VALUES ('a'),('b')) SELECT group_concat(x, '-') FROM t", "a-b")) + # SQLite has no GROUPING SETS, ROLLUP or CUBE, and the driver claims + # none: SQL_GROUP_BY reports SQL_GB_NO_RELATION, not the extensions. + R.run("GROUPING SETS is refused", lambda: refused( + "WITH t(x,y) AS (VALUES (1,1)) SELECT x FROM t GROUP BY GROUPING SETS ((x),(y))", + near="SETS")) + + # -------------------------------------------------------------- + print("\n--- window functions ---") + R.run("row_number", lambda: scalar( + "WITH t(x) AS (VALUES (1),(2),(3)) " + "SELECT max(rn) FROM (SELECT row_number() OVER (ORDER BY x) rn FROM t)", 3)) + R.run("rank with PARTITION BY", lambda: rows( + "WITH t(x,y) AS (VALUES (1,1),(1,2)) " + "SELECT rank() OVER (PARTITION BY x ORDER BY y) FROM t", want_count=2)) + R.run("lag/lead", lambda: rows( + "WITH t(x) AS (VALUES (1),(2),(3)) " + "SELECT lag(x) OVER (ORDER BY x), lead(x) OVER (ORDER BY x) FROM t", + want_count=3)) + R.run("running sum frame", lambda: scalar( + "WITH t(x) AS (VALUES (1),(2),(3)) SELECT max(s) FROM (" + "SELECT sum(x) OVER (ORDER BY x ROWS BETWEEN UNBOUNDED PRECEDING " + "AND CURRENT ROW) s FROM t)", 6)) + + # -------------------------------------------------------------- + print("\n--- subqueries and CTEs ---") + R.run("scalar subquery", lambda: scalar( + "SELECT (SELECT max(x) FROM (WITH t(x) AS (VALUES (1),(2),(3)) SELECT x FROM t))", + 3)) + R.run("IN subquery", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2),(3)), b(y) AS (VALUES (1),(2)) " + "SELECT count(*) FROM a WHERE a.x IN (SELECT y FROM b)", 2)) + R.run("EXISTS subquery", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2)), b(y) AS (VALUES (1)) " + "SELECT count(*) FROM a WHERE EXISTS (SELECT 1 FROM b WHERE b.y = a.x)", 1)) + R.run("correlated subquery", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2)), b(y) AS (VALUES (1)) " + "SELECT count(*) FROM a WHERE a.x = (SELECT max(y) FROM b)", 1)) + R.run("multiple CTEs", lambda: scalar( + "WITH a(x) AS (VALUES (1)), b(y) AS (VALUES (2)) SELECT a.x + b.y FROM a, b", 3)) + R.run("recursive CTE", lambda: scalar( + "WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM c WHERE i < 5) " + "SELECT count(*) FROM c", 5)) + R.run("derived table", lambda: scalar( + "SELECT count(*) FROM (WITH t(x) AS (VALUES (1),(2),(3)) SELECT x FROM t)", 3)) + # SQL_SUBQUERIES deliberately omits SQL_SQ_QUANTIFIED, and + # SQL_SQL92_PREDICATES omits SQL_SP_QUANTIFIED_COMPARISON, because + # SQLite parses neither `< ALL` nor `< ANY`. This is that claim measured + # rather than asserted: a driver advertising it would have BI tools push + # down SQL that cannot run. + R.run("quantified comparison is refused", lambda: refused( + "WITH a(x) AS (VALUES (1),(2)), b(y) AS (VALUES (1)) " + "SELECT count(*) FROM a WHERE a.x > ALL (SELECT y FROM b)", + near="ALL")) + + # -------------------------------------------------------------- + print("\n--- set operations ---") + R.run("UNION", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 UNION SELECT 1 UNION SELECT 2)", 2)) + R.run("UNION ALL", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 UNION ALL SELECT 1)", 2)) + R.run("INTERSECT", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 INTERSECT SELECT 1)", 1)) + R.run("EXCEPT", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 EXCEPT SELECT 2)", 1)) + + # -------------------------------------------------------------- + print("\n--- parameters in every clause that takes one ---") + R.run("parameter in SELECT", lambda: scalar("SELECT CAST(? AS INTEGER)", 7, params=[7])) + R.run("parameter in WHERE", lambda: scalar( + "WITH t(x) AS (VALUES (1),(2),(3)) SELECT count(*) FROM t WHERE x > ?", + 2, params=[1])) + R.run("parameter in HAVING", lambda: rows( + "WITH t(x) AS (VALUES (1),(1),(2)) " + "SELECT x FROM t GROUP BY x HAVING count(*) > ?", want_count=1, params=[1])) + R.run("parameter in IN list", lambda: scalar( + "WITH t(x) AS (VALUES (1),(2),(3)) SELECT count(*) FROM t WHERE x IN (?, ?)", + 2, params=[1, 2])) + R.run("two parameters, order preserved", lambda: scalar( + "SELECT CAST(? AS TEXT) || CAST(? AS TEXT)", "ab", params=["a", "b"])) + R.run("parameter in a join condition", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2)), b(y) AS (VALUES (1),(2)) " + "SELECT count(*) FROM a JOIN b ON a.x = b.y AND a.x > ?", 1, params=[1])) + R.run("NULL parameter", lambda: scalar( + "SELECT CAST(? AS INTEGER) IS NULL", 1, params=[None])) + R.run("parameter in LIMIT", lambda: rows( + "WITH t(x) AS (VALUES (1),(2),(3)) SELECT x FROM t LIMIT ?", + want_count=2, params=[2])) + R.run("parameter reused by re-execution", lambda: ( + scalar("SELECT CAST(? AS INTEGER)", 1, params=[1]), + scalar("SELECT CAST(? AS INTEGER)", 2, params=[2]), + )) + + # -------------------------------------------------------------- + print("\n--- ODBC escape sequences ---") + # `escape_dialect.rs` translates these into SQLite's spelling. Nothing + # else in the suite reaches that module. + R.run("{fn UCASE}", lambda: scalar("SELECT {fn UCASE('abc')}", "ABC")) + R.run("{fn LCASE}", lambda: scalar("SELECT {fn LCASE('ABC')}", "abc")) + R.run("{fn SUBSTRING}", lambda: scalar( + "SELECT {fn SUBSTRING('hello', 2, 3)}", "ell")) + R.run("{fn ASCII}", lambda: scalar("SELECT {fn ASCII('A')}", 65)) + R.run("{fn LENGTH}", lambda: scalar("SELECT {fn LENGTH('abcd')}", 4)) + R.run("{fn ABS}", lambda: scalar("SELECT {fn ABS(-3)}", 3)) + R.run("{fn IFNULL}", lambda: scalar("SELECT {fn IFNULL(NULL, 5)}", 5)) + R.run("{fn CONCAT}", lambda: scalar("SELECT {fn CONCAT('a', 'b')}", "ab")) + # CURDATE/CURTIME/NOW are real callable functions in SQLite once + # renamed, so a bare name swap works. + R.run("{fn CURDATE}", lambda: shape("SELECT {fn CURDATE()}")) + R.run("{fn CURTIME}", lambda: shape("SELECT {fn CURTIME()}")) + R.run("{fn NOW}", lambda: shape("SELECT {fn NOW()}")) + # These three are bare keywords: `SELECT CURRENT_DATE();` is a syntax + # error, so a name swap cannot express them and `rewrite_scalar_fn` + # replaces the whole escape instead. + R.run("{fn CURRENT_DATE} (bare keyword)", lambda: shape("SELECT {fn CURRENT_DATE()}")) + R.run("{fn CURRENT_TIME} (bare keyword)", lambda: shape("SELECT {fn CURRENT_TIME()}")) + R.run("{fn CURRENT_TIMESTAMP} (bare keyword)", + lambda: shape("SELECT {fn CURRENT_TIMESTAMP()}")) + # SQLite has no date/time storage classes, so a date literal is a + # quoted string and the escape renders to exactly that. + R.run("{d} date literal", lambda: scalar("SELECT {d '2020-02-03'}", "2020-02-03")) + R.run("{t} time literal", lambda: scalar("SELECT {t '04:05:06'}", "04:05:06")) + R.run("{ts} timestamp literal", lambda: scalar( + "SELECT {ts '2020-02-03 04:05:06'}", "2020-02-03 04:05:06")) + R.run("{oj} outer join escape", lambda: scalar( + "WITH a(x) AS (VALUES (1),(2),(3)), b(y) AS (VALUES (2)) " + "SELECT count(*) FROM {oj a LEFT OUTER JOIN b ON a.x = b.y}", 3)) + + # -------------------------------------------------------------- + print("\n--- ODBC catalog functions ---") + R.run("SQLTables", lambda: ( + cur.tables(table=PARENT).fetchall() + or (_ for _ in ()).throw(AssertionError("no tables")))) + R.run("SQLTables table-type enumeration", lambda: ( + cur.tables(catalog="", schema="", table="", tableType="%").fetchall() + or (_ for _ in ()).throw(AssertionError("no table types")))) + R.run("SQLColumns", lambda: ( + cur.columns(table=CHILD).fetchall() + or (_ for _ in ()).throw(AssertionError("no columns")))) + R.run("SQLGetTypeInfo", lambda: ( + cur.getTypeInfo().fetchall() + or (_ for _ in ()).throw(AssertionError("no type info")))) + # SQLite has no catalogs and no schemas, and the driver says so, so the + # two enumerations must come back empty rather than inventing one. + R.run("SQLTables catalog enumeration is empty", lambda: ( + cur.tables(catalog="%", schema="", table="").fetchall() == [] + or (_ for _ in ()).throw(AssertionError("a catalog was named")))) + R.run("SQLTables schema enumeration is empty", lambda: ( + cur.tables(catalog="", schema="%", table="").fetchall() == [] + or (_ for _ in ()).throw(AssertionError("a schema was named")))) + + # Unlike Trino, SQLite publishes all three of these, so the assertion + # is on real rows rather than on an empty set not erroring. + def primary_keys_name_the_column(): + found = cur.primaryKeys(table=PARENT).fetchall() + assert found, "no primary key reported" + # COLUMN_NAME is column 4 of the SQLPrimaryKeys result set. + assert found[0][3] == "id", f"expected id, got {found[0][3]!r}" + + R.run("SQLPrimaryKeys names the column", primary_keys_name_the_column) + + def foreign_keys_link_the_tables(): + found = cur.foreignKeys(foreignTable=CHILD).fetchall() + assert found, "no foreign key reported" + # PKTABLE_NAME is 3, PKCOLUMN_NAME 4, FKTABLE_NAME 7, FKCOLUMN_NAME 8. + row = found[0] + assert row[2] == PARENT, f"expected parent {PARENT}, got {row[2]!r}" + # `REFERENCES parent(id)` names the column, and the driver must + # carry it through: PKCOLUMN_NAME is a spec "not NULL" column. + assert row[3] == "id", f"expected id, got {row[3]!r}" + assert row[7] == "parent_id", f"expected parent_id, got {row[7]!r}" + + R.run("SQLForeignKeys links the tables", foreign_keys_link_the_tables) + + def statistics_report_the_index(): + found = cur.statistics(table=CHILD).fetchall() + assert found, "no statistics reported" + # INDEX_NAME is column 6. The table-stat row carries NULL there. + names = {r[5] for r in found if r[5] is not None} + assert f"{CHILD}_label_idx" in names, f"index missing, saw {names}" + + R.run("SQLStatistics reports the index", statistics_report_the_index) + + def special_columns_name_a_row_identifier(): + found = cur.rowIdColumns(table=PARENT).fetchall() + assert found, "no row identifier reported" + + R.run("SQLSpecialColumns names a row identifier", + special_columns_name_a_row_identifier) + + # SQLite has no stored procedures, so an empty result set is the + # correct answer and the assertion is that the call succeeds. + R.run("SQLProcedures (empty is correct)", lambda: cur.procedures().fetchall()) + R.run("SQLProcedureColumns (empty is correct)", + lambda: cur.procedureColumns().fetchall()) + + # -------------------------------------------------------------- + print("\n--- ordering, distinct, and null handling ---") + R.run("ORDER BY on an unselected column", lambda: rows( + "WITH t(x,y) AS (VALUES (1,'b'),(2,'a')) SELECT y FROM t ORDER BY x", + want_count=2)) + R.run("ORDER BY an expression", lambda: rows( + "WITH t(x) AS (VALUES (1),(2)) SELECT x FROM t ORDER BY -x", want_count=2)) + # SQLite sorts NULLs first, which is what SQL_NULL_COLLATION reports as + # SQL_NC_LOW. Core takes NULL placement for the catalog result sets from + # that same hook, so a change here would move those rows too. + R.run("NULLs sort first, as SQL_NC_LOW says", lambda: scalar( + "WITH t(x) AS (VALUES (1),(NULL)) SELECT count(x) FROM " + "(SELECT x FROM t ORDER BY x LIMIT 1)", 0)) + R.run("DISTINCT", lambda: scalar( + "WITH t(x) AS (VALUES (1),(1),(2)) " + "SELECT count(*) FROM (SELECT DISTINCT x FROM t)", 2)) + R.run("CASE expression", lambda: scalar( + "SELECT CASE WHEN 1 = 1 THEN 'y' ELSE 'n' END", "y")) + R.run("COALESCE over NULL", lambda: scalar( + "SELECT coalesce(CAST(NULL AS INTEGER), 5)", 5)) + + # -------------------------------------------------------------- + print("\n--- statement forms with undeclared column lengths ---") + # The driver has to describe a column whose size it cannot know, and an + # application sizes its buffers from what it says. + R.run("EXPLAIN", lambda: shape("EXPLAIN SELECT 1", min_rows=1)) + R.run("EXPLAIN QUERY PLAN", lambda: shape("EXPLAIN QUERY PLAN SELECT 1", min_rows=1)) + R.run("PRAGMA table_info", lambda: shape(f"PRAGMA table_info({PARENT})", min_rows=1)) + R.run("sqlite_master", lambda: shape( + "SELECT name, type FROM sqlite_master ORDER BY name", min_cols=2)) + finally: + drop_fixture(cur) + cur.close() + conn.close() + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) From 262210771b3d75a46c5e074b19ad7f3d2be46471 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 17:59:21 +0200 Subject: [PATCH 39/50] test: add BI stress tests over a generated star schema Asks the shapes `test_sql_surface.py` covers, at a size nothing else reaches: multi-table joins, UNIONs, subqueries, CTEs, window functions, 50,000-row fetches, wide rows, and a prepared statement re-executed two hundred times. Size is the point rather than a detail. `exec_direct` materialises every row before returning, so a large result set is where that decision is felt, and the suites so far all work in tens of rows. The fixture is generated by recursive CTE rather than shipped, so the repository carries no data file and the size is three constants at the top of the module. It lives in a database of its own, `generated/stress.db`, so the shared one keeps the size the other suites expect and a half-finished stress run cannot affect them. One scenario needed rewriting rather than porting: SQLite refuses a LIMIT on an operand of a compound SELECT ("LIMIT clause should come after UNION ALL not before") where Trino accepts it, so each branch is wrapped in a subquery to make the LIMIT bind per branch. No counterpart to the Trino driver's `parse_profile.py` and `profile_stress.sh` is included, deliberately. Those attribute a query's time between the coordinator and the client, and SQLite has no server-side half: the query runs in-process inside the same shared object. `cargo bench` already measures fetch throughput, which is the question that remains here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- integration-tests/README.md | 15 + integration-tests/perf/test_stress.py | 398 +++++++++++++++++++++++++ integration-tests/scripts/lib.sh | 5 + integration-tests/scripts/run-tests.sh | 7 + 4 files changed, 425 insertions(+) create mode 100644 integration-tests/perf/test_stress.py diff --git a/integration-tests/README.md b/integration-tests/README.md index 7c9b448..25faae7 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -31,6 +31,7 @@ Both take `--help`. | `suites/test_sql_surface.py` | The SQL a BI tool emits, the ODBC escapes and the catalog functions, run once per connection style | | `suites/test_c_abi.py` | The C ABI pen test, run once | | `suites/test_type_matrix.py` | Type-transform fuzz and column description, run once | +| `perf/test_stress.py` | BI query patterns over a generated star schema, run once | | `generated/` | Everything `setup.sh` writes. Gitignored | | `windows/` | The VM suite, its libvirt definitions, and [WINDOWS.md](windows/WINDOWS.md) | @@ -89,6 +90,20 @@ is a separate question from what `SQLGetData` will hand over: SQLite gives a computed column no declared type, so the driver answers from the storage class of the values, and a tool decides from that whether a column can be summed. +Last, `perf/test_stress.py`, which asks the same shapes of SQL at size: +multi-table joins over a generated star schema, 50,000-row fetches, wide rows +and a prepared statement re-executed two hundred times. That matters more here +than for a client-server driver, because `exec_direct` materialises every row +before returning, so a large result set is where that decision is felt. It +builds its fixture with recursive CTEs and works in a database of its own, +`generated/stress.db`, so the shared one keeps the size the other suites +expect. + +There is no counterpart to the Trino driver's `perf/parse_profile.py` and +`perf/profile_stress.sh`. Those split a query's time between the coordinator +and the client, and SQLite has no server-side half to attribute anything to. +`cargo bench` measures fetch throughput instead. + It then runs `cargo test`, so that one command gives a developer the whole suite. CI passes `--skip-cargo-test`, since its pre-commit job has already run exactly that via the `cargo-test` hook. diff --git a/integration-tests/perf/test_stress.py b/integration-tests/perf/test_stress.py new file mode 100644 index 0000000..0fbdfc0 --- /dev/null +++ b/integration-tests/perf/test_stress.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +""" +BI stress tests for the SQLite ODBC driver. + +Exercises the query patterns a BI tool emits, at a size the rest of the suite +does not reach: multi-table joins over a star schema, UNIONs, subqueries, CTEs, +window functions, large result sets and wide rows. + +Where `test_sql_surface.py` asks whether a shape of SQL works at all, this asks +whether it still works over tens of thousands of rows. That matters more here +than for a client-server driver, because `exec_direct` materialises every row +before returning: a result set is held in memory in full, so a large one is the +case where that decision is felt. See the eager-materialisation section of +AGENTS.md. + +The fixture is generated by recursive CTE rather than shipped, so the suite +carries no data file and the size is a constant at the top of this module. + +There is no counterpart to the Trino driver's `parse_profile.py` and +`profile_stress.sh`. Those attribute a query's time between the coordinator and +the client, and SQLite has no server-side half: the query runs in-process, +inside the same shared object. `cargo bench` (`benches/fetch_sqlite.rs`) is +where fetch throughput is measured instead. + +Usage: + python3 integration-tests/perf/test_stress.py \ + "Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db" + python3 integration-tests/perf/test_stress.py "DSN=test_sqlite" + +Needs no server. Requires `pyodbc`, normally through `uv run --with pyodbc`. +""" + +import os +import sys + +import pyodbc + +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "suites") +) + +from harness import Results, Target # noqa: E402 + +R = Results("stress") + +CUSTOMERS = 5_000 +ITEMS = 500 +SALES = 50_000 +# Customers above this have no sales, so a LEFT JOIN has NULLs to produce. +CUSTOMERS_WITH_SALES = 4_000 + +CUSTOMER = "stress_customer" +ITEM = "stress_item" +SALES_TABLE = "stress_sales" + + +def make_fixture(cur): + """A small star schema, generated in three statements. + + Sales are dropped first and inserted last: the driver turns foreign-key + enforcement on for every connection, so the order is not optional. + """ + cur.execute(f"DROP TABLE IF EXISTS {SALES_TABLE}") + cur.execute(f"DROP TABLE IF EXISTS {CUSTOMER}") + cur.execute(f"DROP TABLE IF EXISTS {ITEM}") + + cur.execute( + f"CREATE TABLE {CUSTOMER} (" + " id INTEGER PRIMARY KEY," + " first_name TEXT," + " last_name TEXT," + " region TEXT" + ")" + ) + cur.execute( + f"CREATE TABLE {ITEM} (id INTEGER PRIMARY KEY, product_name TEXT, category TEXT)" + ) + cur.execute( + f"CREATE TABLE {SALES_TABLE} (" + " id INTEGER PRIMARY KEY," + f" customer_id INTEGER REFERENCES {CUSTOMER}(id)," + f" item_id INTEGER REFERENCES {ITEM}(id)," + " channel TEXT," + " quantity INTEGER," + " net_paid REAL" + ")" + ) + + # Every tenth customer has a NULL last name, so the NULL probes have + # something to find in a column that is not the join key. + cur.execute( + f"INSERT INTO {CUSTOMER} (id, first_name, last_name, region) " + "WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM seq " + f"WHERE i < {CUSTOMERS}) " + "SELECT i, 'first' || i, " + "CASE WHEN i % 10 = 0 THEN NULL ELSE 'last' || i END, " + "'region' || (i % 10) FROM seq" + ) + cur.execute( + f"INSERT INTO {ITEM} (id, product_name, category) " + "WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM seq " + f"WHERE i < {ITEMS}) " + "SELECT i, 'product' || i, 'category' || (i % 20) FROM seq" + ) + cur.execute( + f"INSERT INTO {SALES_TABLE} " + "(id, customer_id, item_id, channel, quantity, net_paid) " + "WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM seq " + f"WHERE i < {SALES}) " + f"SELECT i, (i % {CUSTOMERS_WITH_SALES}) + 1, (i % {ITEMS}) + 1, " + "CASE WHEN i % 2 = 0 THEN 'store' ELSE 'web' END, " + "(i % 7) + 1, " + # Every hundredth sale has no amount, so the aggregates meet a NULL. + "CASE WHEN i % 100 = 0 THEN NULL ELSE ((i % 1000) + 1) * 1.5 END " + "FROM seq" + ) + + +def drop_fixture(cur): + for table in (SALES_TABLE, CUSTOMER, ITEM): + try: + cur.execute(f"DROP TABLE IF EXISTS {table}") + except Exception: # noqa: BLE001 + pass + + +def main(): + target = Target.from_argv( + sys.argv, + "usage: test_stress.py " + '"Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db"', + ) + conn = pyodbc.connect(target.conn_str(), autocommit=True) + cur = conn.cursor() + + print(f"building the fixture: {CUSTOMERS} customers, {ITEMS} items, {SALES} sales") + make_fixture(cur) + + try: + # -------------------------------------------------------------- + # Multi-table joins + # -------------------------------------------------------------- + def two_table_join(): + cur.execute(f""" + SELECT c.first_name, c.last_name, SUM(s.net_paid) AS total_spend + FROM {CUSTOMER} c + JOIN {SALES_TABLE} s ON c.id = s.customer_id + GROUP BY c.first_name, c.last_name + ORDER BY total_spend DESC + LIMIT 10 + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 rows, got {len(rows)}" + for row in rows: + assert row[2] is not None and row[2] > 0, f"total_spend was {row[2]!r}" + + R.run("Two-table INNER JOIN with aggregation", two_table_join) + + def three_table_star_join(): + cur.execute(f""" + SELECT c.first_name, i.product_name, SUM(s.quantity) AS total_qty + FROM {SALES_TABLE} s + JOIN {CUSTOMER} c ON s.customer_id = c.id + JOIN {ITEM} i ON s.item_id = i.id + GROUP BY c.first_name, i.product_name + ORDER BY total_qty DESC + LIMIT 10 + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 rows, got {len(rows)}" + for row in rows: + assert row[2] is not None, "total_qty should not be NULL" + + R.run("Three-table star-schema JOIN", three_table_star_join) + + def left_join_nulls(): + cur.execute(f""" + SELECT c.id, c.first_name, s.id + FROM {CUSTOMER} c + LEFT JOIN {SALES_TABLE} s ON c.id = s.customer_id + WHERE c.id > {CUSTOMERS_WITH_SALES} + ORDER BY c.id + LIMIT 50 + """) + rows = cur.fetchall() + assert len(rows) == 50, f"expected 50 rows, got {len(rows)}" + assert all(row[2] is None for row in rows), ( + "every customer past the sales range must join to NULL" + ) + + R.run("LEFT JOIN producing NULLs", left_join_nulls) + + # -------------------------------------------------------------- + # Subqueries and CTEs + # -------------------------------------------------------------- + def in_subquery(): + cur.execute(f""" + SELECT id, first_name FROM {CUSTOMER} + WHERE id IN ( + SELECT customer_id FROM {SALES_TABLE} WHERE net_paid > 1000 + ) + ORDER BY id + LIMIT 10 + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 rows, got {len(rows)}" + for row in rows: + assert row[0] > 0, f"id should be > 0, got {row[0]}" + + R.run("IN subquery over the fact table", in_subquery) + + def correlated_subquery(): + cur.execute(f""" + SELECT c.id, + (SELECT COUNT(*) FROM {SALES_TABLE} s WHERE s.customer_id = c.id) AS n + FROM {CUSTOMER} c + WHERE c.id <= 20 + ORDER BY c.id + """) + rows = cur.fetchall() + assert len(rows) == 20, f"expected 20 rows, got {len(rows)}" + assert all(row[1] > 0 for row in rows), "each of these customers has sales" + + R.run("Correlated subquery", correlated_subquery) + + def cte(): + cur.execute(f""" + WITH per_region AS ( + SELECT c.region, SUM(s.net_paid) AS spend + FROM {CUSTOMER} c + JOIN {SALES_TABLE} s ON c.id = s.customer_id + GROUP BY c.region + ) + SELECT region, spend FROM per_region ORDER BY spend DESC + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 regions, got {len(rows)}" + + R.run("CTE / WITH clause", cte) + + # -------------------------------------------------------------- + # Set operations + # -------------------------------------------------------------- + def union_all(): + # Each branch is wrapped in a subquery because SQLite refuses a + # LIMIT on an operand of a compound SELECT ("LIMIT clause should + # come after UNION ALL not before"), where Trino accepts it. The + # LIMIT has to bind per branch here, not to the union. + cur.execute(f""" + SELECT customer_id FROM ( + SELECT customer_id FROM {SALES_TABLE} + WHERE channel = 'store' LIMIT 1000 + ) + UNION ALL + SELECT customer_id FROM ( + SELECT customer_id FROM {SALES_TABLE} + WHERE channel = 'web' LIMIT 1000 + ) + """) + rows = cur.fetchall() + assert len(rows) == 2000, f"expected 2000 rows, got {len(rows)}" + + R.run("UNION ALL across sales channels", union_all) + + def union_dedup(): + cur.execute(f""" + SELECT region FROM {CUSTOMER} + UNION + SELECT region FROM {CUSTOMER} + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 distinct regions, got {len(rows)}" + + R.run("UNION with dedup", union_dedup) + + # -------------------------------------------------------------- + # Result-set shapes + # -------------------------------------------------------------- + def large_result_set(): + cur.execute(f"SELECT id, customer_id, net_paid FROM {SALES_TABLE} ORDER BY id") + rows = cur.fetchall() + assert len(rows) == SALES, f"expected {SALES} rows, got {len(rows)}" + # Ordered, so a dropped or duplicated row shows up as a gap rather + # than only as a count that happens to match. + assert rows[0][0] == 1 and rows[-1][0] == SALES, ( + f"first and last ids were {rows[0][0]} and {rows[-1][0]}" + ) + + R.run(f"Fetch all {SALES} rows", large_result_set) + + def wide_result_set(): + cur.execute(f""" + SELECT s.id, s.customer_id, s.item_id, s.channel, s.quantity, s.net_paid, + c.first_name, c.last_name, c.region, i.product_name, i.category + FROM {SALES_TABLE} s + JOIN {CUSTOMER} c ON s.customer_id = c.id + JOIN {ITEM} i ON s.item_id = i.id + LIMIT 5000 + """) + assert len(cur.description) == 11, ( + f"expected 11 columns, got {len(cur.description)}" + ) + rows = cur.fetchall() + assert len(rows) == 5000, f"expected 5000 rows, got {len(rows)}" + assert len(rows[0]) == 11, f"expected 11 values, got {len(rows[0])}" + + R.run("Wide result set (11 columns)", wide_result_set) + + def nulls_in_various_positions(): + cur.execute(f""" + SELECT id, last_name, region FROM {CUSTOMER} + WHERE id % 10 = 0 + ORDER BY id + LIMIT 100 + """) + rows = cur.fetchall() + assert len(rows) == 100, f"expected 100 rows, got {len(rows)}" + assert all(row[1] is None for row in rows), ( + "every tenth customer has a NULL last name" + ) + assert all(row[0] is not None and row[2] is not None for row in rows), ( + "the columns either side of the NULL must survive it" + ) + + R.run("NULLs in various column positions", nulls_in_various_positions) + + def empty_result_set(): + cur.execute(f"SELECT id FROM {CUSTOMER} WHERE id < 0") + rows = cur.fetchall() + assert rows == [], f"expected no rows, got {len(rows)}" + # The describe still has to work: an application sizes its buffers + # from it before it knows the set is empty. + assert cur.description is not None, "an empty result set still has columns" + + R.run("Empty result set", empty_result_set) + + # -------------------------------------------------------------- + # Aggregation and windows at size + # -------------------------------------------------------------- + def group_by_having_on_a_join(): + cur.execute(f""" + SELECT c.region, COUNT(*) AS n, SUM(s.net_paid) AS spend + FROM {CUSTOMER} c + JOIN {SALES_TABLE} s ON c.id = s.customer_id + GROUP BY c.region + HAVING COUNT(*) > 100 + ORDER BY spend DESC + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 regions, got {len(rows)}" + assert all(row[1] > 100 for row in rows), "HAVING was not applied" + + R.run("GROUP BY + HAVING on a JOIN", group_by_having_on_a_join) + + def window_function(): + cur.execute(f""" + SELECT id, customer_id, rn FROM ( + SELECT s.id, s.customer_id, + ROW_NUMBER() OVER (PARTITION BY s.customer_id ORDER BY s.id) AS rn + FROM {SALES_TABLE} s + WHERE s.customer_id <= 100 + ) + WHERE rn = 1 + ORDER BY customer_id + """) + rows = cur.fetchall() + assert len(rows) == 100, f"expected 100 partitions, got {len(rows)}" + assert all(row[2] == 1 for row in rows), "every row must be its partition's first" + + R.run("Window function (ROW_NUMBER OVER PARTITION BY)", window_function) + + # -------------------------------------------------------------- + # The parameter path, repeated + # -------------------------------------------------------------- + def prepared_statement_reused(): + """A prepared statement re-executed many times. + + This is what a BI tool does when it pages through a dimension, and + it is the path where a leak or a stale cursor would accumulate + rather than show up once. + """ + sql = f"SELECT COUNT(*) FROM {SALES_TABLE} WHERE customer_id = ?" + total = 0 + for customer_id in range(1, 201): + total += cur.execute(sql, [customer_id]).fetchone()[0] + assert total > 0, "the repeated query found nothing" + + R.run("Prepared statement re-executed 200 times", prepared_statement_reused) + finally: + drop_fixture(cur) + cur.close() + conn.close() + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/scripts/lib.sh b/integration-tests/scripts/lib.sh index db5aa41..3761819 100644 --- a/integration-tests/scripts/lib.sh +++ b/integration-tests/scripts/lib.sh @@ -10,6 +10,7 @@ TEST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_DIR="$(cd "$TEST_DIR/.." && pwd)" SUITES_DIR="$TEST_DIR/suites" +PERF_DIR="$TEST_DIR/perf" WINDOWS_DIR="$TEST_DIR/windows" # Everything setup.sh writes lands here, and the whole directory is gitignored. @@ -17,6 +18,10 @@ WINDOWS_DIR="$TEST_DIR/windows" # absolute path, so neither survives being moved to another checkout. GENERATED="$TEST_DIR/generated" DB_PATH="$GENERATED/test.db" +# The stress suite generates 50k rows. It gets a database of its own so the +# shared one keeps the size the other suites expect, and so a stress run left +# half-finished cannot affect them. SQLite creates the file on first connect. +STRESS_DB_PATH="$GENERATED/stress.db" ODBC_INI="$GENERATED/odbc.ini" ODBCINST_INI="$GENERATED/odbcinst.ini" diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh index d5c417f..01ae125 100755 --- a/integration-tests/scripts/run-tests.sh +++ b/integration-tests/scripts/run-tests.sh @@ -96,6 +96,13 @@ python3 "$SUITES_DIR/test_c_abi.py" "Driver=$DRIVER_PATH;Database=$DB_PATH" echo "=== Running type-transform fuzz (no Driver Manager) ===" python3 "$SUITES_DIR/test_type_matrix.py" "Driver=$DRIVER_PATH;Database=$DB_PATH" +# Against its own database, for the reason lib.sh gives. Once, because the +# scenarios are about result-set size rather than about how the connection was +# opened. +echo "=== Running BI stress tests ===" +uv run --with pyodbc python3 "$PERF_DIR/test_stress.py" \ + "Driver=$DRIVER_PATH;Database=$STRESS_DB_PATH" + # Run by default so that a developer invoking this script gets the whole suite # in one command. CI passes --skip-cargo-test, because its pre-commit job has # already run exactly this via the cargo-test hook, and repeating it there means From 9a115fc4c558ed61ac7050fab0c2b221d45c81c1 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 18:16:10 +0200 Subject: [PATCH 40/50] test: fold test_integration.py onto the harness, and run every suite on Windows Two fixes and one gap. `test_integration.py` created `types_test` with `IF NOT EXISTS` and dropped it at the end, so it removed a table `create_test_db.sql` owns and left whatever ran next to find it missing. Its fixtures are now named `it_*`, created and dropped by this suite alone. That is what made the shared database look corrupted mid-session earlier. It also predated `harness.py` and carried its own `run()` and PASS/FAIL counters, so it was the one suite whose output did not match the rest. It now uses `Results` and `Target` like the others. Three stale coercions went with that: `int(count)` around `COUNT(*)`, and a comment saying it "may come back as str depending on column type metadata". That was true until computed columns started being typed from their values, and papering over it now would hide a regression rather than tolerate a known one. Every test is kept, including the ones `test_sql_surface.py` duplicates, because until this commit `test_integration.py` was the only suite the Windows VM ran and its breadth was the whole of Windows coverage. Three of them are unique anywhere: `SQLRowCount` for INSERT/UPDATE/DELETE, Unicode round-tripping through core's UTF-16 marshalling, and the `SQLGetData` path that a pyodbc output converter forces in place of `SQLBindCol`. That "only suite the VM runs" was a deploy-list limitation rather than a real constraint, so it is gone too. `windows_test.py` now ships `harness.py`, `odbc_abi.py` and all six suites, and runs them the way the Linux runner does: the pyodbc ones per connection style, the ctypes ones once, the stress suite once against its own database. It records every result instead of stopping at the first failure, since one Windows-only defect should not hide the next and a VM round trip is expensive enough that a second run to find out is a real cost. The Windows half is unverified: this machine has no VM. The Linux run is green across all six suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- integration-tests/README.md | 9 +- integration-tests/suites/test_integration.py | 697 ++++++++++--------- integration-tests/windows/WINDOWS.md | 30 +- integration-tests/windows/windows_test.py | 109 ++- 4 files changed, 488 insertions(+), 357 deletions(-) diff --git a/integration-tests/README.md b/integration-tests/README.md index 25faae7..6d7ea6c 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -124,10 +124,11 @@ silently ignored. ## Windows `windows/windows_test.py` deploys the cross-compiled DLL to a provisioned -libvirt VM over WinRM, registers it, and runs the same -`suites/test_integration.py` through the Windows Driver Manager, DSN-less and -then via a DSN. The Windows DM is much stricter than unixODBC and tends to fail -silently, so this is measured rather than assumed. +libvirt VM over WinRM, registers it, and runs the same suites through the +Windows Driver Manager: the three pyodbc ones DSN-less and then via a DSN, the +two ctypes ones once each, and the stress suite once. The Windows DM is much +stricter than unixODBC and tends to fail silently, so this is measured rather +than assumed. See [windows/WINDOWS.md](windows/WINDOWS.md) for provisioning the VM. diff --git a/integration-tests/suites/test_integration.py b/integration-tests/suites/test_integration.py index d36d700..137c488 100755 --- a/integration-tests/suites/test_integration.py +++ b/integration-tests/suites/test_integration.py @@ -3,60 +3,68 @@ Integration tests for the SQLite ODBC driver. Runs through the ODBC Driver Manager (unixODBC on Linux, odbc32.dll on Windows) -using pyodbc. Tests DDL, DML, queries, aggregation, joins, and parameterised +using pyodbc: DDL, DML, queries, aggregation, joins and parameterised statements. -Usage: - python3 integration-tests/suites/test_integration.py "Driver=/path/to/driver.so;Database=/path/to/test.db" - python3 integration-tests/suites/test_integration.py "Driver=C:\\path\\to\\driver.dll;Database=C:\\test.db" +Three things here are covered nowhere else: + + - **`SQLRowCount`.** The affected-row count of an INSERT, UPDATE and DELETE, + which is the observable half of the `row_count` rule in AGENTS.md: `None` + and `Some(0)` mean different things, and answering the wrong one turns a + `CREATE TABLE` into `SQL_NO_DATA`. + - **Unicode round-tripping.** Japanese, emoji and accented Latin through + core's UTF-16 marshalling and back. + - **`SQLGetData` rather than `SQLBindCol`.** Registering a pyodbc output + converter makes it fetch the column with `SQLGetData(SQL_C_BINARY)` instead + of binding it, which is a different path through the driver. + +The rest overlaps `test_sql_surface.py` on purpose: this is the one suite the +Windows VM ran before the others were ported, so its breadth is what Windows +coverage rested on. -Requires: pip install pyodbc +Fixture tables are named for this suite (`it_*`) and dropped at the end. They +deliberately do not reuse `types_test` from `create_test_db.sql`: this suite +used to create that name with `IF NOT EXISTS` and then drop it, which removed a +table it did not own and left whatever ran next to find it missing. + +Usage: + python3 integration-tests/suites/test_integration.py \ + "Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db" + python3 integration-tests/suites/test_integration.py "DSN=test_sqlite" """ +import os import sys -import pyodbc -passed = 0 -failed = 0 +import pyodbc +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -def run(label, fn): - """Run a test function, print PASS/FAIL, track counts.""" - global passed, failed - try: - fn() - print(f"PASS {label}") - passed += 1 - except Exception as e: - print(f"FAIL {label}: {e}") - failed += 1 +from harness import Results, Target # noqa: E402 +R = Results("integration") -def main(): - if len(sys.argv) != 2: - print(f"Usage: {sys.argv[0]} <connection-string>") - sys.exit(2) +EMPLOYEES = "it_employees" +TYPES = "it_types" - conn_str = sys.argv[1] - conn = pyodbc.connect(conn_str, autocommit=True) - cur = conn.cursor() - # === Setup: create test tables === - cur.execute(""" - CREATE TABLE IF NOT EXISTS employees ( +def make_fixture(cur): + cur.execute(f"DROP TABLE IF EXISTS {EMPLOYEES}") + cur.execute(f""" + CREATE TABLE {EMPLOYEES} ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, salary REAL, active BOOLEAN ) """) - cur.execute("DELETE FROM employees") - cur.execute("INSERT INTO employees VALUES (1, 'Alice', 75000.50, 1)") - cur.execute("INSERT INTO employees VALUES (2, 'Bob', 62000.00, 0)") - cur.execute("INSERT INTO employees VALUES (3, 'Charlie', 91000.25, 1)") + cur.execute(f"INSERT INTO {EMPLOYEES} VALUES (1, 'Alice', 75000.50, 1)") + cur.execute(f"INSERT INTO {EMPLOYEES} VALUES (2, 'Bob', 62000.00, 0)") + cur.execute(f"INSERT INTO {EMPLOYEES} VALUES (3, 'Charlie', 91000.25, 1)") - cur.execute(""" - CREATE TABLE IF NOT EXISTS types_test ( + cur.execute(f"DROP TABLE IF EXISTS {TYPES}") + cur.execute(f""" + CREATE TABLE {TYPES} ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL, @@ -66,293 +74,352 @@ def main(): created_at TEXT ) """) - cur.execute("DELETE FROM types_test") - cur.execute("INSERT INTO types_test VALUES (1, 'Widget', 9.99, 100, 1, X'DEADBEEF', '2026-01-15T10:30:00')") - cur.execute("INSERT INTO types_test VALUES (2, 'Gadget', 24.50, NULL, 0, NULL, '2026-02-20T14:00:00')") - cur.execute("INSERT INTO types_test VALUES (3, 'Doohickey', 0.50, 9999, 1, X'00', '2026-03-01T00:00:00')") - - # ------------------------------------------------------------------ - # SELECT basics - # ------------------------------------------------------------------ - def test_select_all(): - cur.execute("SELECT * FROM employees ORDER BY id") - rows = cur.fetchall() - assert len(rows) == 3, f"expected 3 rows, got {len(rows)}" - assert rows[0][1] == "Alice" - assert rows[1][1] == "Bob" - assert rows[2][1] == "Charlie" - - run("SELECT all rows", test_select_all) - - def test_select_where(): - cur.execute("SELECT name, salary FROM employees WHERE id = 1") - row = cur.fetchone() - assert row is not None - assert row[0] == "Alice" - assert abs(row[1] - 75000.50) < 0.01 - - run("SELECT with WHERE", test_select_where) - - def test_select_count(): - cur.execute("SELECT COUNT(*) FROM employees") - count = cur.fetchone()[0] - # SQLite COUNT(*) may come back as str depending on column type metadata - assert int(count) == 3, f"expected 3, got {count!r}" - - run("SELECT COUNT(*)", test_select_count) - - def test_select_empty(): - cur.execute("SELECT * FROM employees WHERE id = 999") - assert cur.fetchone() is None - - run("SELECT with no matching rows", test_select_empty) - - # ------------------------------------------------------------------ - # DDL + DML - # ------------------------------------------------------------------ - def test_create_insert_drop(): - cur.execute("DROP TABLE IF EXISTS temp_test") - cur.execute("CREATE TABLE temp_test (id INTEGER PRIMARY KEY, val TEXT)") - cur.execute("INSERT INTO temp_test VALUES (1, 'hello')") - cur.execute("INSERT INTO temp_test VALUES (2, 'world')") - cur.execute("SELECT COUNT(*) FROM temp_test") - assert int(cur.fetchone()[0]) == 2 - cur.execute("DROP TABLE temp_test") - - run("CREATE + INSERT + DROP", test_create_insert_drop) - - def test_insert_row_count(): - cur.execute("DROP TABLE IF EXISTS rc_test") - cur.execute("CREATE TABLE rc_test (id INTEGER PRIMARY KEY, val TEXT)") - count = cur.execute("INSERT INTO rc_test VALUES (1, 'a')").rowcount - assert count == 1, f"expected rowcount 1, got {count}" - cur.execute("DROP TABLE rc_test") - - run("INSERT rowcount", test_insert_row_count) - - def test_update(): - cur.execute("UPDATE employees SET salary = 80000.00 WHERE name = 'Alice'") - cur.execute("SELECT salary FROM employees WHERE name = 'Alice'") - assert abs(cur.fetchone()[0] - 80000.00) < 0.01 - # restore - cur.execute("UPDATE employees SET salary = 75000.50 WHERE name = 'Alice'") - - run("UPDATE + verify", test_update) - - def test_update_row_count(): - count = cur.execute("UPDATE employees SET salary = salary WHERE active = 1").rowcount - assert count == 2, f"expected rowcount 2, got {count}" - - run("UPDATE rowcount", test_update_row_count) - - def test_delete(): - cur.execute("INSERT INTO employees VALUES (99, 'Temp', 10000, 1)") - count = cur.execute("DELETE FROM employees WHERE id = 99").rowcount - assert count == 1, f"expected rowcount 1, got {count}" - cur.execute("SELECT * FROM employees WHERE id = 99") - assert cur.fetchone() is None - - run("DELETE + verify", test_delete) - - # ------------------------------------------------------------------ - # Aggregation - # ------------------------------------------------------------------ - def test_group_by(): - cur.execute("DROP TABLE IF EXISTS orders") - cur.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)") - for row in [(1,'Alice',29.99),(2,'Bob',49.99),(3,'Alice',49.99),(4,'Bob',29.99),(5,'Alice',99.99)]: - cur.execute("INSERT INTO orders VALUES (?,?,?)", row) - cur.execute("SELECT customer, COUNT(*), SUM(amount) FROM orders GROUP BY customer ORDER BY customer") - rows = cur.fetchall() - assert len(rows) == 2 - assert rows[0][0] == "Alice" - assert int(rows[0][1]) == 3 - assert abs(float(rows[0][2]) - 179.97) < 0.01 - assert rows[1][0] == "Bob" - assert int(rows[1][1]) == 2 - cur.execute("DROP TABLE orders") - - run("GROUP BY + COUNT + SUM", test_group_by) - - def test_having(): - cur.execute("DROP TABLE IF EXISTS orders2") - cur.execute("CREATE TABLE orders2 (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)") - for row in [(1,'Alice',10),(2,'Alice',20),(3,'Bob',30)]: - cur.execute("INSERT INTO orders2 VALUES (?,?,?)", row) - cur.execute("SELECT customer, COUNT(*) AS cnt FROM orders2 GROUP BY customer HAVING cnt > 1") - rows = cur.fetchall() - assert len(rows) == 1 - assert rows[0][0] == "Alice" - cur.execute("DROP TABLE orders2") - - run("GROUP BY + HAVING", test_having) - - def test_order_by(): - cur.execute("SELECT name FROM employees ORDER BY salary DESC") - names = [r[0] for r in cur.fetchall()] - assert names == ["Charlie", "Alice", "Bob"], f"got {names}" - - run("ORDER BY DESC", test_order_by) - - # ------------------------------------------------------------------ - # JOIN - # ------------------------------------------------------------------ - def test_join(): - cur.execute("DROP TABLE IF EXISTS departments") - cur.execute("CREATE TABLE departments (id INTEGER PRIMARY KEY, dept TEXT)") - cur.execute("INSERT INTO departments VALUES (1, 'Engineering')") - cur.execute("INSERT INTO departments VALUES (2, 'Marketing')") - cur.execute(""" - SELECT e.name, d.dept - FROM employees e JOIN departments d ON e.id = d.id - ORDER BY e.id - """) - rows = cur.fetchall() - assert len(rows) == 2 - assert rows[0][0] == "Alice" and rows[0][1] == "Engineering" - assert rows[1][0] == "Bob" and rows[1][1] == "Marketing" - cur.execute("DROP TABLE departments") - - run("JOIN", test_join) - - # ------------------------------------------------------------------ - # Parameterised queries (folded from test_params.py) - # ------------------------------------------------------------------ - def test_param_select_int(): - cur.execute("SELECT name, price FROM types_test WHERE id = ?", (1,)) - row = cur.fetchone() - assert row is not None - assert row[0] == "Widget" - assert abs(row[1] - 9.99) < 1e-9 - - run("Param: SELECT by integer", test_param_select_int) - - def test_param_select_string(): - cur.execute("SELECT id, price FROM types_test WHERE name = ?", ("Gadget",)) - row = cur.fetchone() - assert row is not None - assert row[0] == 2 - - run("Param: SELECT by string", test_param_select_string) - - def test_param_no_rows(): - cur.execute("SELECT id FROM types_test WHERE id = ?", (999,)) - assert cur.fetchone() is None - - run("Param: no matching rows", test_param_no_rows) - - def test_param_null_column(): - cur.execute("SELECT quantity FROM types_test WHERE id = ?", (2,)) - row = cur.fetchone() - assert row is not None - assert row[0] is None, f"expected NULL, got {row[0]!r}" - - run("Param: NULL column", test_param_null_column) - - def test_param_multiple_rows(): - cur.execute("SELECT id FROM types_test WHERE active = ? ORDER BY id", (1,)) - ids = [r[0] for r in cur.fetchall()] - assert ids == [1, 3], f"expected [1, 3], got {ids}" - - run("Param: multiple rows", test_param_multiple_rows) - - def test_param_insert(): - cur.execute( - "INSERT INTO types_test (id, name, price, quantity, active) VALUES (?, ?, ?, ?, ?)", - (100, "TestItem", 1.23, 42, 1), - ) - cur.execute("SELECT name, price, quantity FROM types_test WHERE id = ?", (100,)) - row = cur.fetchone() - assert row is not None - assert row[0] == "TestItem" - assert abs(row[1] - 1.23) < 1e-9 - assert row[2] == 42 - cur.execute("DELETE FROM types_test WHERE id = 100") - - run("Param: INSERT + verify", test_param_insert) - - def test_param_reexecute(): - expected = {1: "Widget", 2: "Gadget", 3: "Doohickey"} - for id_, name in expected.items(): - cur.execute("SELECT name FROM types_test WHERE id = ?", (id_,)) + cur.execute( + f"INSERT INTO {TYPES} VALUES " + "(1, 'Widget', 9.99, 100, 1, X'DEADBEEF', '2026-01-15T10:30:00')" + ) + cur.execute( + f"INSERT INTO {TYPES} VALUES " + "(2, 'Gadget', 24.50, NULL, 0, NULL, '2026-02-20T14:00:00')" + ) + cur.execute( + f"INSERT INTO {TYPES} VALUES " + "(3, 'Doohickey', 0.50, 9999, 1, X'00', '2026-03-01T00:00:00')" + ) + + +def drop_fixture(cur): + for table in (EMPLOYEES, TYPES): + try: + cur.execute(f"DROP TABLE IF EXISTS {table}") + except Exception: # noqa: BLE001 + pass + + +def main(): + target = Target.from_argv( + sys.argv, + "usage: test_integration.py " + '"Driver=/path/to/libstackable_odbc_sqlite.so;Database=/path/to/test.db"', + ) + conn = pyodbc.connect(target.conn_str(), autocommit=True) + cur = conn.cursor() + + make_fixture(cur) + try: + # -------------------------------------------------------------- + print("--- SELECT basics ---") + + def select_all(): + cur.execute(f"SELECT * FROM {EMPLOYEES} ORDER BY id") + rows = cur.fetchall() + assert len(rows) == 3, f"expected 3 rows, got {len(rows)}" + assert [r[1] for r in rows] == ["Alice", "Bob", "Charlie"] + + R.run("SELECT all rows", select_all) + + def select_where(): + cur.execute(f"SELECT name, salary FROM {EMPLOYEES} WHERE id = 1") row = cur.fetchone() - assert row is not None, f"no row for id={id_}" - assert row[0] == name, f"id={id_}: expected {name!r}, got {row[0]!r}" + assert row is not None + assert row[0] == "Alice" + assert abs(row[1] - 75000.50) < 0.01 + + R.run("SELECT with WHERE", select_where) + + def select_count(): + # Compared against an int, not coerced with `int()`. `count(*)` is a + # computed column, and the driver types it from the storage class of + # the value, so a string here would be a regression rather than + # something to work around. `test_type_matrix.py` tests that + # properly. + cur.execute(f"SELECT COUNT(*) FROM {EMPLOYEES}") + count = cur.fetchone()[0] + assert count == 3, f"expected 3, got {count!r}" + + R.run("SELECT COUNT(*)", select_count) + + def select_empty(): + cur.execute(f"SELECT * FROM {EMPLOYEES} WHERE id = 999") + assert cur.fetchone() is None + + R.run("SELECT with no matching rows", select_empty) + + # -------------------------------------------------------------- + print("\n--- DDL and DML ---") + + def create_insert_drop(): + cur.execute("DROP TABLE IF EXISTS it_temp") + cur.execute("CREATE TABLE it_temp (id INTEGER PRIMARY KEY, val TEXT)") + cur.execute("INSERT INTO it_temp VALUES (1, 'hello')") + cur.execute("INSERT INTO it_temp VALUES (2, 'world')") + cur.execute("SELECT COUNT(*) FROM it_temp") + assert cur.fetchone()[0] == 2 + cur.execute("DROP TABLE it_temp") + + R.run("CREATE + INSERT + DROP", create_insert_drop) + + # The three row-count probes. `SQLRowCount` has to distinguish "no + # applicable count" from "counted zero": core turns a zero-column + # statement reporting 0 into SQL_NO_DATA, so a DDL statement answering + # the same way would look to an application like a DELETE that matched + # nothing. + def insert_row_count(): + cur.execute("DROP TABLE IF EXISTS it_rowcount") + cur.execute("CREATE TABLE it_rowcount (id INTEGER PRIMARY KEY, val TEXT)") + count = cur.execute("INSERT INTO it_rowcount VALUES (1, 'a')").rowcount + assert count == 1, f"expected rowcount 1, got {count}" + cur.execute("DROP TABLE it_rowcount") + + R.run("INSERT rowcount", insert_row_count) + + def update_and_verify(): + cur.execute(f"UPDATE {EMPLOYEES} SET salary = 80000.00 WHERE name = 'Alice'") + cur.execute(f"SELECT salary FROM {EMPLOYEES} WHERE name = 'Alice'") + assert abs(cur.fetchone()[0] - 80000.00) < 0.01 + cur.execute(f"UPDATE {EMPLOYEES} SET salary = 75000.50 WHERE name = 'Alice'") + + R.run("UPDATE + verify", update_and_verify) + + def update_row_count(): + count = cur.execute( + f"UPDATE {EMPLOYEES} SET salary = salary WHERE active = 1" + ).rowcount + assert count == 2, f"expected rowcount 2, got {count}" + + R.run("UPDATE rowcount", update_row_count) + + def delete_and_verify(): + cur.execute(f"INSERT INTO {EMPLOYEES} VALUES (99, 'Temp', 10000, 1)") + count = cur.execute(f"DELETE FROM {EMPLOYEES} WHERE id = 99").rowcount + assert count == 1, f"expected rowcount 1, got {count}" + cur.execute(f"SELECT * FROM {EMPLOYEES} WHERE id = 99") + assert cur.fetchone() is None + + R.run("DELETE + verify", delete_and_verify) + + # -------------------------------------------------------------- + print("\n--- aggregation ---") + + def group_by(): + cur.execute("DROP TABLE IF EXISTS it_orders") + cur.execute( + "CREATE TABLE it_orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)" + ) + for row in [ + (1, "Alice", 29.99), + (2, "Bob", 49.99), + (3, "Alice", 49.99), + (4, "Bob", 29.99), + (5, "Alice", 99.99), + ]: + cur.execute("INSERT INTO it_orders VALUES (?,?,?)", row) + cur.execute( + "SELECT customer, COUNT(*), SUM(amount) FROM it_orders " + "GROUP BY customer ORDER BY customer" + ) + rows = cur.fetchall() + assert len(rows) == 2 + assert rows[0][0] == "Alice" + assert rows[0][1] == 3, f"expected 3, got {rows[0][1]!r}" + assert abs(rows[0][2] - 179.97) < 0.01 + assert rows[1][0] == "Bob" + assert rows[1][1] == 2, f"expected 2, got {rows[1][1]!r}" + cur.execute("DROP TABLE it_orders") + + R.run("GROUP BY + COUNT + SUM", group_by) + + def having(): + cur.execute("DROP TABLE IF EXISTS it_orders2") + cur.execute( + "CREATE TABLE it_orders2 (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)" + ) + for row in [(1, "Alice", 10), (2, "Alice", 20), (3, "Bob", 30)]: + cur.execute("INSERT INTO it_orders2 VALUES (?,?,?)", row) + cur.execute( + "SELECT customer, COUNT(*) AS cnt FROM it_orders2 " + "GROUP BY customer HAVING cnt > 1" + ) + rows = cur.fetchall() + assert len(rows) == 1 + assert rows[0][0] == "Alice" + cur.execute("DROP TABLE it_orders2") + + R.run("GROUP BY + HAVING", having) + + def order_by(): + cur.execute(f"SELECT name FROM {EMPLOYEES} ORDER BY salary DESC") + names = [r[0] for r in cur.fetchall()] + assert names == ["Charlie", "Alice", "Bob"], f"got {names}" + + R.run("ORDER BY DESC", order_by) + + def join(): + cur.execute("DROP TABLE IF EXISTS it_departments") + cur.execute("CREATE TABLE it_departments (id INTEGER PRIMARY KEY, dept TEXT)") + cur.execute("INSERT INTO it_departments VALUES (1, 'Engineering')") + cur.execute("INSERT INTO it_departments VALUES (2, 'Marketing')") + cur.execute(f""" + SELECT e.name, d.dept + FROM {EMPLOYEES} e JOIN it_departments d ON e.id = d.id + ORDER BY e.id + """) + rows = cur.fetchall() + assert len(rows) == 2 + assert rows[0][0] == "Alice" and rows[0][1] == "Engineering" + assert rows[1][0] == "Bob" and rows[1][1] == "Marketing" + cur.execute("DROP TABLE it_departments") + + R.run("JOIN", join) + + # -------------------------------------------------------------- + print("\n--- parameterised statements ---") + + def param_select_int(): + cur.execute(f"SELECT name, price FROM {TYPES} WHERE id = ?", (1,)) + row = cur.fetchone() + assert row is not None + assert row[0] == "Widget" + assert abs(row[1] - 9.99) < 1e-9 - run("Param: re-execute with different values", test_param_reexecute) + R.run("Param: SELECT by integer", param_select_int) - def test_param_null(): - cur.execute( - "INSERT INTO types_test (id, name, price) VALUES (?, ?, ?)", - (101, "NullPrice", None), - ) - cur.execute("SELECT price FROM types_test WHERE id = ?", (101,)) - row = cur.fetchone() - assert row is not None - assert row[0] is None, f"expected NULL, got {row[0]!r}" - cur.execute("DELETE FROM types_test WHERE id = 101") - - run("Param: NULL binding", test_param_null) - - # ------------------------------------------------------------------ - # Unicode roundtrip - # ------------------------------------------------------------------ - def test_unicode_roundtrip(): - cur.execute("DROP TABLE IF EXISTS unicode_test") - cur.execute("CREATE TABLE unicode_test (id INTEGER PRIMARY KEY, val TEXT)") - values = [ - (1, "日本語"), - (2, "🎉🦀"), - (3, "café résumé"), - (4, "Ünïcödé"), - ] - for row in values: - cur.execute("INSERT INTO unicode_test VALUES (?, ?)", row) - cur.execute("SELECT id, val FROM unicode_test ORDER BY id") - rows = cur.fetchall() - assert len(rows) == len(values), f"expected {len(values)} rows, got {len(rows)}" - for (row_id, row_val), (exp_id, exp_val) in zip(rows, values): - assert row_id == exp_id - assert row_val == exp_val, f"id={exp_id}: expected {exp_val!r}, got {row_val!r}" - cur.execute("DROP TABLE unicode_test") - - run("Unicode roundtrip (Japanese, emoji, accents)", test_unicode_roundtrip) - - # ------------------------------------------------------------------ - # SQLGetData type coercion - # ------------------------------------------------------------------ - def test_getdata_integer_as_char(): - import struct - # Our driver maps SQLite INTEGER columns to SQL_BIGINT (-5). - # Registering an output converter causes pyodbc to skip SQLBindCol and - # instead call SQLGetData(SQL_C_BINARY) for that column, exercising the - # integer→binary coercion path. The converter decodes the 8-byte LE value. - SQL_BIGINT = -5 - received = [] - def decode_bigint(b): - val = struct.unpack("<q", b)[0] - received.append(val) - return val - conn.add_output_converter(SQL_BIGINT, decode_bigint) - try: - cur.execute("SELECT id FROM employees ORDER BY id") - ids = [r[0] for r in cur.fetchall()] - assert received == [1, 2, 3], f"SQLGetData not called or wrong raw values: {received!r}" - assert ids == [1, 2, 3], f"expected [1, 2, 3], got {ids!r}" - finally: - conn.clear_output_converters() + def param_select_string(): + cur.execute(f"SELECT id, price FROM {TYPES} WHERE name = ?", ("Gadget",)) + row = cur.fetchone() + assert row is not None + assert row[0] == 2 + + R.run("Param: SELECT by string", param_select_string) - run("SQLGetData type coercion: INTEGER via add_output_converter", test_getdata_integer_as_char) + def param_no_rows(): + cur.execute(f"SELECT id FROM {TYPES} WHERE id = ?", (999,)) + assert cur.fetchone() is None - # === Cleanup === - cur.execute("DROP TABLE IF EXISTS employees") - cur.execute("DROP TABLE IF EXISTS types_test") - conn.close() + R.run("Param: no matching rows", param_no_rows) - # === Summary === - print(f"\n{passed} passed, {failed} failed") - sys.exit(1 if failed else 0) + def param_null_column(): + cur.execute(f"SELECT quantity FROM {TYPES} WHERE id = ?", (2,)) + row = cur.fetchone() + assert row is not None + assert row[0] is None, f"expected NULL, got {row[0]!r}" + + R.run("Param: NULL column", param_null_column) + + def param_multiple_rows(): + cur.execute(f"SELECT id FROM {TYPES} WHERE active = ? ORDER BY id", (1,)) + ids = [r[0] for r in cur.fetchall()] + assert ids == [1, 3], f"expected [1, 3], got {ids}" + + R.run("Param: multiple rows", param_multiple_rows) + + def param_insert(): + cur.execute( + f"INSERT INTO {TYPES} (id, name, price, quantity, active) " + "VALUES (?, ?, ?, ?, ?)", + (100, "TestItem", 1.23, 42, 1), + ) + cur.execute(f"SELECT name, price, quantity FROM {TYPES} WHERE id = ?", (100,)) + row = cur.fetchone() + assert row is not None + assert row[0] == "TestItem" + assert abs(row[1] - 1.23) < 1e-9 + assert row[2] == 42 + cur.execute(f"DELETE FROM {TYPES} WHERE id = 100") + + R.run("Param: INSERT + verify", param_insert) + + def param_reexecute(): + expected = {1: "Widget", 2: "Gadget", 3: "Doohickey"} + for id_, name in expected.items(): + cur.execute(f"SELECT name FROM {TYPES} WHERE id = ?", (id_,)) + row = cur.fetchone() + assert row is not None, f"no row for id={id_}" + assert row[0] == name, f"id={id_}: expected {name!r}, got {row[0]!r}" + + R.run("Param: re-execute with different values", param_reexecute) + + def param_null_binding(): + cur.execute( + f"INSERT INTO {TYPES} (id, name, price) VALUES (?, ?, ?)", + (101, "NullPrice", None), + ) + cur.execute(f"SELECT price FROM {TYPES} WHERE id = ?", (101,)) + row = cur.fetchone() + assert row is not None + assert row[0] is None, f"expected NULL, got {row[0]!r}" + cur.execute(f"DELETE FROM {TYPES} WHERE id = 101") + + R.run("Param: NULL binding", param_null_binding) + + # -------------------------------------------------------------- + print("\n--- marshalling ---") + + def unicode_roundtrip(): + """Text through core's UTF-16 marshalling and back. + + SQLWCHAR is 16-bit, so an emoji is a surrogate pair and a mistake in + the conversion shows up here and nowhere else in the suite. + """ + cur.execute("DROP TABLE IF EXISTS it_unicode") + cur.execute("CREATE TABLE it_unicode (id INTEGER PRIMARY KEY, val TEXT)") + values = [ + (1, "日本語"), + (2, "🎉🦀"), + (3, "café résumé"), + (4, "Ünïcödé"), + ] + for row in values: + cur.execute("INSERT INTO it_unicode VALUES (?, ?)", row) + cur.execute("SELECT id, val FROM it_unicode ORDER BY id") + rows = cur.fetchall() + assert len(rows) == len(values), f"expected {len(values)} rows, got {len(rows)}" + for (row_id, row_val), (exp_id, exp_val) in zip(rows, values): + assert row_id == exp_id + assert row_val == exp_val, ( + f"id={exp_id}: expected {exp_val!r}, got {row_val!r}" + ) + cur.execute("DROP TABLE it_unicode") + + R.run("Unicode roundtrip (Japanese, emoji, accents)", unicode_roundtrip) + + def getdata_instead_of_bindcol(): + """The `SQLGetData` path rather than `SQLBindCol`. + + Registering an output converter makes pyodbc stop binding the column + and call `SQLGetData(SQL_C_BINARY)` for it instead, which is a + different route through the driver. The converter decodes the + 8-byte little-endian value, so it also pins that an INTEGER column + is delivered as `SQL_BIGINT`. + """ + import struct + + SQL_BIGINT = -5 + received = [] + + def decode_bigint(b): + val = struct.unpack("<q", b)[0] + received.append(val) + return val + + conn.add_output_converter(SQL_BIGINT, decode_bigint) + try: + cur.execute(f"SELECT id FROM {EMPLOYEES} ORDER BY id") + ids = [r[0] for r in cur.fetchall()] + assert received == [1, 2, 3], ( + f"SQLGetData not called, or wrong raw values: {received!r}" + ) + assert ids == [1, 2, 3], f"expected [1, 2, 3], got {ids!r}" + finally: + conn.clear_output_converters() + + R.run("SQLGetData rather than SQLBindCol", getdata_instead_of_bindcol) + finally: + drop_fixture(cur) + conn.close() + + return R.summary() if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/integration-tests/windows/WINDOWS.md b/integration-tests/windows/WINDOWS.md index c0e707a..62871d2 100644 --- a/integration-tests/windows/WINDOWS.md +++ b/integration-tests/windows/WINDOWS.md @@ -1,10 +1,10 @@ # Windows testing -`suites/test_integration.py`, driven through the Windows ODBC Driver Manager -over WinRM. The Windows DM is far stricter than unixODBC and tends to fail -silently, so this is measured rather than assumed. The target is a disposable -Windows Server VM on a host-only libvirt network, created by the Ansible -playbook in `vm/`. +The integration suites, driven through the Windows ODBC Driver Manager over +WinRM. The Windows DM is far stricter than unixODBC and tends to fail silently, +so this is measured rather than assumed. The target is a disposable Windows +Server VM on a host-only libvirt network, created by the Ansible playbook in +`vm/`. The VM's credentials are `Administrator` / `Asdf1234`, the defaults in `windows_test.py`. They are not a secret: the machine is local, throwaway, and @@ -30,9 +30,23 @@ Then run from the Linux host. `uv` installs `pywinrm` itself: uv run --with pywinrm python3 integration-tests/windows/windows_test.py ``` -The suite runs twice against the same database, DSN-less and then via a DSN, -exactly as it does on Linux. Nothing needs to be running on the host: SQLite is -compiled into the DLL, and the script copies a freshly built database to the VM. +This runs the same suites the Linux runner does, in the same shapes: + +| Suite | How | +|---|---| +| `test_integration.py`, `test_transactions.py`, `test_sql_surface.py` | Through the Windows Driver Manager, once DSN-less and once via a DSN | +| `test_c_abi.py`, `test_type_matrix.py` | Loading the DLL with `ctypes`, so no Driver Manager is in the loop. Once | +| `perf/test_stress.py` | Once, against a database of its own | + +Every suite runs and every result is recorded; the script does not stop at the +first failure, because one Windows-only defect should not hide the next and a VM +round trip is slow enough that finding out costs a second run. + +`harness.py` and `odbc_abi.py` are deployed alongside, flat in `C:\odbc_test`, +which is where each suite's own `sys.path` entry looks for them. + +Nothing needs to be running on the host: SQLite is compiled into the DLL, and +the script copies a freshly built database to the VM. **Do not diagnose a Windows failure without rebuilding the DLL first.** `--skip-build` reuses whatever sits in `target/x86_64-pc-windows-gnu/release/`, diff --git a/integration-tests/windows/windows_test.py b/integration-tests/windows/windows_test.py index a6b2c36..9deb04c 100644 --- a/integration-tests/windows/windows_test.py +++ b/integration-tests/windows/windows_test.py @@ -33,11 +33,31 @@ DIALOG_SCRIPT = PROJECT_DIR / "packaging" / "windows" / "configure-dsn.ps1" +PERF_DIR = TEST_DIR / "perf" + REMOTE_DIR = r"C:\odbc_test" REMOTE_DLL = rf"{REMOTE_DIR}\stackable_odbc_sqlite.dll" -REMOTE_TEST = rf"{REMOTE_DIR}\test_integration.py" REMOTE_DIALOG = rf"{REMOTE_DIR}\configure-dsn.ps1" REMOTE_DB = rf"{REMOTE_DIR}\test.db" +# The stress suite generates 50k rows, so it gets a database of its own for the +# same reason it does on Linux. See `STRESS_DB_PATH` in scripts/lib.sh. +REMOTE_STRESS_DB = rf"{REMOTE_DIR}\stress.db" + +# Everything lands flat in REMOTE_DIR, and each suite puts its own directory on +# `sys.path`, so `from harness import ...` resolves to the copy deployed here. +SUPPORT_FILES = ("harness.py", "odbc_abi.py") + +# Run through the Windows Driver Manager, once per connection style. The DM is +# far stricter than unixODBC and tends to fail silently, which is the whole +# reason for running any of this in a VM. +DM_SUITES = ("test_integration.py", "test_transactions.py", "test_sql_surface.py") + +# Load the DLL with ctypes, so no Driver Manager is in the loop. Run once: a +# second pass through a DSN would reach the same code by a longer route, and +# these never resolve a data source at all. +DIRECT_SUITES = ("test_c_abi.py", "test_type_matrix.py") + +STRESS_SUITE = "test_stress.py" DRIVER_NAME = "stackable_odbc_sqlite" DSN_NAME = "test_sqlite" @@ -62,7 +82,17 @@ def main(): build_dll(args.target) dll_path = resolve_dll_path(args.target) - test_path = SUITES_DIR / "test_integration.py" + # Name -> local path, for everything the VM needs. Assembled here so a suite + # added to one of the tuples above is deployed without touching the + # download plumbing. + payload = {name: SUITES_DIR / name for name in SUPPORT_FILES} + payload.update({name: SUITES_DIR / name for name in DM_SUITES}) + payload.update({name: SUITES_DIR / name for name in DIRECT_SUITES}) + payload[STRESS_SUITE] = PERF_DIR / STRESS_SUITE + missing = [name for name, path in payload.items() if not path.exists()] + if missing: + print(f"ERROR: suite files not found: {', '.join(missing)}", file=sys.stderr) + sys.exit(1) host = args.host or discover_vm_ip(args.vm_network) @@ -96,51 +126,72 @@ def main(): print(f"=== Deploying files via HTTP ===") files_to_serve = { dll_path.name: dll_path, - "test_integration.py": test_path, # The setup dialog, which the driver's ConfigDSN looks for *beside its # own DLL* and fails without. A DLL deployed here without it would # answer the ODBC Administrator's Add... button with an error, so the # two travel together the same way install.bat ships them together. "configure-dsn.ps1": DIALOG_SCRIPT, + **payload, } with http_file_server(files_to_serve) as port: base_url = f"http://{args.gateway}:{port}" - download_ps = ( - f'$ProgressPreference = "SilentlyContinue"; ' - f'Invoke-WebRequest -Uri "{base_url}/{dll_path.name}" ' - f'-OutFile "{REMOTE_DLL}"; ' - f'Invoke-WebRequest -Uri "{base_url}/test_integration.py" ' - f'-OutFile "{REMOTE_TEST}"; ' - f'Invoke-WebRequest -Uri "{base_url}/configure-dsn.ps1" ' - f'-OutFile "{REMOTE_DIALOG}"' - ) + # One request per file, in one PowerShell invocation. The DLL and the + # dialog have fixed destinations; everything else lands in REMOTE_DIR + # under its own name, which is what puts `harness.py` beside the suites + # that import it. + downloads = [ + f'Invoke-WebRequest -Uri "{base_url}/{dll_path.name}" -OutFile "{REMOTE_DLL}"', + f'Invoke-WebRequest -Uri "{base_url}/configure-dsn.ps1" -OutFile "{REMOTE_DIALOG}"', + ] + [ + f'Invoke-WebRequest -Uri "{base_url}/{name}" -OutFile "{REMOTE_DIR}\\{name}"' + for name in payload + ] + download_ps = '$ProgressPreference = "SilentlyContinue"; ' + "; ".join(downloads) r = session.run_ps(download_ps) if r.status_code != 0: stderr = r.std_err.decode() print(f"ERROR: file download failed:\n{stderr}", file=sys.stderr) sys.exit(1) print(f" DLL: {dll_path.stat().st_size / 1024:.0f} KB") - print(f" test_integration.py: {test_path.stat().st_size / 1024:.0f} KB") print(f" configure-dsn.ps1: {DIALOG_SCRIPT.stat().st_size / 1024:.0f} KB") + print(f" suites: {', '.join(sorted(payload))}") print("=== Registering ODBC driver ===") register_driver(session) - # --- Run 1: DSN-less connection string --- - print("=== Running integration tests (DSN-less) ===") - conn_str = f"Driver={DRIVER_NAME};Database={REMOTE_DB}" - exit_code = run_tests(session, conn_str) - if exit_code != 0: - sys.exit(exit_code) - - # --- Run 2: DSN-based connection --- print("=== Registering DSN ===") register_dsn(session) - print("=== Running integration tests (via DSN) ===") - dsn_conn_str = f"DSN={DSN_NAME}" - exit_code = run_tests(session, dsn_conn_str) - sys.exit(exit_code) + # Every suite is run, and every result recorded, rather than stopping at the + # first failure. One Windows-only defect should not hide the next, and a + # VM round trip is slow enough that a second run to find out is expensive. + failures = [] + + def run(label, script, conn_str): + print(f"=== {label} ===") + if run_suite(session, script, conn_str) != 0: + failures.append(label) + + dsn_less = f"Driver={DRIVER_NAME};Database={REMOTE_DB}" + for suite in DM_SUITES: + run(f"{suite} (DSN-less)", suite, dsn_less) + for suite in DM_SUITES: + run(f"{suite} (DSN)", suite, f"DSN={DSN_NAME}") + + # These load the DLL themselves, so the connection string names its path + # rather than the registered driver. + direct = f"Driver={REMOTE_DLL};Database={REMOTE_DB}" + for suite in DIRECT_SUITES: + run(suite, suite, direct) + + run(STRESS_SUITE, STRESS_SUITE, f"Driver={REMOTE_DLL};Database={REMOTE_STRESS_DB}") + + print() + if failures: + print(f"FAILED on Windows: {', '.join(failures)}", file=sys.stderr) + sys.exit(1) + print("All Windows suites passed") + sys.exit(0) def parse_args(): @@ -359,11 +410,9 @@ def _port_available(port: int) -> bool: return False -def run_tests(session, conn_str: str) -> int: - """Run test_integration.py on the VM and return the exit code.""" - r = session.run_ps( - f'& {REMOTE_PYTHON} {REMOTE_TEST} "{conn_str}"' - ) +def run_suite(session, script: str, conn_str: str) -> int: + """Run one suite on the VM and return its exit code.""" + r = session.run_ps(f'& {REMOTE_PYTHON} {REMOTE_DIR}\\{script} "{conn_str}"') stdout = r.std_out.decode("utf-8", errors="replace") print(stdout, end="") From 0826383a424bd74323e807ae233473c5c817d8a9 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 18:22:30 +0200 Subject: [PATCH 41/50] fix: name the registered driver, not the DLL, for the stress suite on Windows `test_stress.py` goes through pyodbc like the other Driver Manager suites, but it was handed the DLL path that the two ctypes suites need. unixODBC resolves `Driver=` as either a name or a path; the Windows Driver Manager looks it up in ODBCINST.INI only, and answered IM002. Verified on the VM: all six suites now pass on Windows, in the same shapes and with the same counts as on Linux. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- integration-tests/windows/windows_test.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/integration-tests/windows/windows_test.py b/integration-tests/windows/windows_test.py index 9deb04c..7728862 100644 --- a/integration-tests/windows/windows_test.py +++ b/integration-tests/windows/windows_test.py @@ -184,7 +184,15 @@ def run(label, script, conn_str): for suite in DIRECT_SUITES: run(suite, suite, direct) - run(STRESS_SUITE, STRESS_SUITE, f"Driver={REMOTE_DLL};Database={REMOTE_STRESS_DB}") + # Through the Driver Manager like the other pyodbc suites, so this names the + # *registered driver* rather than the DLL. Windows resolves `Driver=` as a + # name in ODBCINST.INI and answers IM002 for a path, where unixODBC accepts + # either. + run( + STRESS_SUITE, + STRESS_SUITE, + f"Driver={DRIVER_NAME};Database={REMOTE_STRESS_DB}", + ) print() if failures: From bcec269be621d11cff581600b98434f2c8673f79 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 19:08:51 +0200 Subject: [PATCH 42/50] chore(deps): move stackable-odbc-core to afad4f6 Eleven commits, of which five are fixes an application can observe: a float narrowing that overflowed wrote a wrong value instead of reporting 22003; a float target reported diagnostics its spec row does not define; SQLGetDescRecW counted its buffer and length in bytes where the spec counts characters; MAX_NTS_SCAN refused long SQL_NTS statements that every other driver executes; and undocumented off-table SQLSTATEs now trip the diagnostics guard. The rest are documentation. The lock was still pinned to 5bc0417, so none of it was reaching this driver. Verified after the bump: cargo test green, both Linux integration runs green, and all six suites green on the Windows VM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6fa56fb..161ec47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "stackable-odbc-core" version = "0.0.1" -source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#5bc04176095b0881c3cd5620d9546c3f593ef96c" +source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#afad4f67e45402142b58ef13b4455fddc2a69dfe" dependencies = [ "odbc-sys", "snafu", From 92873d09bff62fd6eb4841c454fd48f241a5e4bb Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 19:09:05 +0200 Subject: [PATCH 43/50] fix: an out-of-range SQLGetData column is 07009, not a general error Spec (SQLGetData, Diagnostics): 07009 "Invalid descriptor index" covers a column number "greater than the number of columns in the result set". That clause carries no (DM) marker, so the Driver Manager does not supply it and the driver has to. Core does not range-check the ordinal itself. Its doc comment says the check is "delegated to the backend", so whatever SQLSTATE `SqliteStatement::get_data` chooses is what the application sees, and it chose `SqlState::general_error()`. An application asking for column 99 of a two-column result was told HY000, which says only that something went wrong and nothing about which argument was wrong. Confirmed through unixODBC as well as against the raw entry point: the Driver Manager passes it straight through. The column-0 arm gets the same SQLSTATE. It is unreachable through SQLGetData, because core rejects the bookmark ordinal before calling, but leaving it as a general error would mean the two ways of naming a column that does not exist disagreed depending on which layer caught it. `describe_col`'s equivalent arms are deliberately left alone. Core range-checks that hook against `StatementBackend::column_count` before calling it and documents HY000 as the right answer for a genuine failure there, so those arms are unreachable defensive code rather than a second instance of this bug. The regression test probes one past the last column and u16::MAX -- asserting only the first would pass for an implementation that special-cased count + 1 -- and reads a real column afterwards, so it cannot be satisfied by refusing every ordinal. Checked to fail without the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- src/backend/execute.rs | 18 ++++++++- src/ffi_integration_tests.rs | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/backend/execute.rs b/src/backend/execute.rs index d4ec102..227c96d 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -392,6 +392,17 @@ impl StatementBackend for SqliteStatement { } } + /// Spec (`SQLGetData`, Diagnostics): a column number "greater than the + /// number of columns in the result set" is `07009`, and that clause of the + /// row carries no **(DM)** marker, so it is this driver's to return. + /// + /// Core reaches the backend for the range check rather than doing it + /// itself, so `SqlState::general_error()` here was what an application + /// actually saw for an out-of-range ordinal: `HY000`, which says nothing + /// about which argument was wrong. The column-0 arm is unreachable through + /// `SQLGetData` — core rejects the bookmark ordinal before calling — but it + /// answers `07009` too, so the two ways of naming a column that does not + /// exist cannot disagree depending on which layer caught it. fn get_data( &mut self, col: u16, @@ -402,7 +413,10 @@ impl StatementBackend for SqliteStatement { return Err(OdbcError::NoResultSet.into()); } let col_idx = (col as usize).checked_sub(1).ok_or_else(|| { - OdbcError::general("Column index must be >= 1", SqlState::general_error()) + OdbcError::general( + "Column index must be >= 1", + SqlState::invalid_descriptor_index(), + ) })?; let row = &self.rows[self.cursor as usize]; row.get(col_idx) @@ -414,7 +428,7 @@ impl StatementBackend for SqliteStatement { col, row.len() ), - SqlState::general_error(), + SqlState::invalid_descriptor_index(), ) .into() }) diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index 5e06be1..de363a6 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -3054,6 +3054,82 @@ fn get_data_truncates_string_returns_success_with_info() { } } +// --------------------------------------------------------------------------- +// P1: SQLGetData column ordinal past the last column +// --------------------------------------------------------------------------- + +/// Spec (`SQLGetData`, Diagnostics): `07009` "Invalid descriptor index" for a +/// column number "greater than the number of columns in the result set". That +/// clause carries no **(DM)** marker, so the Driver Manager does not supply it +/// and the driver has to. +/// +/// Core does not range-check the ordinal itself — it asks the backend and +/// reports whatever SQLSTATE comes back — so this is `SqliteStatement::get_data` +/// being asserted through the entry point an application actually calls. It +/// answered `HY000` until the ordinal check was given its own SQLSTATE, which +/// told an application only that *something* went wrong. +/// +/// Both ends of the range are probed. One past the last column is the case that +/// regressed; `u16::MAX` is the same condition reached by a wildly wrong +/// ordinal, and asserting only the first would pass for an implementation that +/// special-cased `count + 1`. +#[test] +fn get_data_column_past_the_last_is_invalid_descriptor_index() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_memory(conn), SqlReturn::SUCCESS); + + assert_eq!(exec_direct(stmt, "SELECT 1, 2"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<SqliteBackend>(stmt), + SqlReturn::SUCCESS + ); + + for col in [3u16, u16::MAX] { + let mut value: i64 = 0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<SqliteBackend>( + stmt, + col, + CDataType::SBigInt as i16, + &raw mut value as *mut c_void, + 8, + &mut ind, + ); + assert_eq!( + ret, + SqlReturn::ERROR, + "column {col} does not exist, so the call must fail" + ); + assert_eq!( + last_sqlstate(stmt), + "07009", + "column {col} is past the last column, which the spec's \ + SQLGetData diagnostics table calls 07009" + ); + } + + // The last real column still reads, so the check above cannot be + // satisfied by refusing every ordinal. + let mut value: i64 = 0; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<SqliteBackend>( + stmt, + 2, + CDataType::SBigInt as i16, + &raw mut value as *mut c_void, + 8, + &mut ind, + ), + SqlReturn::SUCCESS + ); + assert_eq!(value, 2); + + cleanup(env, conn, stmt); + } +} + // --------------------------------------------------------------------------- // P1: Fetch after NO_DATA returns NO_DATA again (not ERROR) // --------------------------------------------------------------------------- From dd234d72ebbc8ccb531e643c1f6f5a7be6dbe98e Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 19:10:13 +0200 Subject: [PATCH 44/50] test: cover the SQLGetData buffer contract, the escape boundaries and hostile catalog arguments Three gaps a pen test of the driver turned up, none of which any suite reached. `test_c_abi.py` gains the SQLGetData buffer contract, which is the part of that call an application cannot avoid and a Driver Manager does not implement: exact fit, one byte short, the indicator reporting the untruncated length, the zero-length length probe writing nothing, a negative BufferLength refused with HY090, a wide indicator counted in bytes rather than characters, and the 07009 ordinal check the previous commit fixed. Buffers are guard-filled before each call, so a write past the length the driver was given is visible rather than landing in memory that happened to be zero. The one worth naming is chunked retrieval: a second SQLGetData call has to continue the value, not restart it. A driver that restarts turns the documented drain loop into an infinite one, and no amount of correct data compensates for that. It is asserted by reassembling 26 characters out of ten-byte reads and requiring the loop to end in NO_DATA. `test_sql_surface.py` gains the other half of escape translation. Everything there proved the rewriter fires; nothing proved it stops. A {fn ...} inside a string literal, inside either comment form, or inside any of SQLite's three identifier-quoting styles has to survive verbatim, because a rewrite there changes the value a query returns with no error anywhere -- corruption rather than failure. A doubled quote is included, since mishandling '' ends the literal early and rewrites the rest. It also gains the scalar-function bitmaps as a contract. A SQL_*_FUNCTIONS bit is a promise: a BI tool emits {fn NAME(...)} only for the bits the driver sets, so a set bit whose escape does not execute is a query the tool will build and the driver will reject. Every bit in all four bitmaps is read back from SQLGetInfo and the matching call executed with spec-shaped arguments -- which is what would catch a name mapped to a SQLite function of a different signature, the reason LOCATE is deliberately absent. Nothing else tied `info.rs`'s bitmaps to `escape_dialect.rs`'s remap table. All 22 currently advertised functions pass. Last, the catalog functions are given hostile names. They are the only path in the driver that turns a caller-supplied argument into SQL, and for a BI tool that argument is often typed into a filter box. The fixture grows a table whose name contains a single quote, which can only be found if the arguments are bound rather than interpolated. The injection payloads close a literal and issue a DROP against the fixture, and the check is that the fixture is still standing afterwards: "no exception" would also pass for a driver that ran them. `%` and `_` are asserted to still work as patterns, which rules out satisfying all of it by escaping everything indiscriminately. CHANGELOG records one limitation the pen test established rather than fixed: SQL_C_NUMERIC cannot be used to retrieve a value. A DECIMAL column is described as SQL_DECIMAL and reads correctly as SQL_C_CHAR or SQL_C_DOUBLE, but SQLGetData and a bound column both report 07006, while the same type works as a parameter. That lives in core, which has no arm for it in `write_column_value`. Linux is green: 125 in the SQL surface suite, 132 in the C ABI one, and every other suite unchanged. The new checks are unverified on Windows -- the VM was shut down before they were written -- though nothing in them is platform-specific. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 5 + integration-tests/README.md | 30 ++- integration-tests/suites/test_c_abi.py | 165 +++++++++++++- integration-tests/suites/test_sql_surface.py | 228 ++++++++++++++++++- 4 files changed, 423 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccef413..3d90992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,11 @@ describes what was linked, including the bundled SQLite. - SQLite has no catalogs and no schemas, so the driver reports none rather than inventing a one-level hierarchy. - SQLite has no stored procedures, so those lookups return no rows. +- `SQL_C_NUMERIC` cannot be used to *retrieve* a value. A `DECIMAL` column is + described as `SQL_DECIMAL` and reads correctly as `SQL_C_CHAR` or + `SQL_C_DOUBLE`, but `SQLGetData` and a column bound to `SQL_C_NUMERIC` both + report `07006`. It works as a parameter type, so the restriction is on the + retrieval side only. - Rows are fetched one at a time. `SQL_ATTR_ROW_ARRAY_SIZE` and `SQL_ATTR_PARAMSET_SIZE` are both pinned at 1, so there are no block cursors and no parameter arrays. diff --git a/integration-tests/README.md b/integration-tests/README.md index 6d7ea6c..25c2b34 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -69,14 +69,40 @@ swapped. Where the Trino driver can only check that a key or index lookup returns nothing without erroring, this one asserts the rows, because SQLite publishes all three. +It also asserts the two things that translation getting *too* eager would +break. First, the boundaries: a `{fn ...}` inside a string literal, a comment, +or any of SQLite's three identifier-quoting styles has to survive verbatim, +because rewriting there changes the value a query returns with no error +anywhere. Second, the bitmaps as a contract: every bit set in +`SQL_STRING_FUNCTIONS` and its three siblings is read back from `SQLGetInfo` +and the matching `{fn NAME(...)}` executed with spec-shaped arguments. A BI +tool emits an escape only for the bits the driver sets, so a set bit that does +not execute is a query the tool will build and the driver will reject, and +nothing else ties `info.rs`'s bitmaps to `escape_dialect.rs`'s remap table. + +Last in that suite, the catalog functions are given hostile names. They are the +only path in the driver that turns a caller-supplied argument into SQL, and for +a BI tool that argument is often typed into a filter box. A table whose name +contains a quote has to be found, payloads that close a literal and issue a +`DROP` have to be treated as names that match nothing — asserted by re-counting +the fixture afterwards, since "no exception" would also pass for a driver that +ran them — and `%` and `_` have to keep working as patterns, which rules out +escaping everything indiscriminately. + Then `test_c_abi.py`, once. It loads the driver's `.so` with `ctypes` and calls the exported entry points with **no Driver Manager in the loop**, which is the point: unixODBC answers a large part of the ODBC state machine itself, so what the driver does with an out-of-order or malformed call is invisible to anything going through pyodbc. It covers handle lifecycle and parentage, stale handles and double frees, cursor state, attribute round-trips, the query timeout, and -transactions. A DSN run would reach the same code by a longer route, so there -is only one. +transactions. It also covers the `SQLGetData` buffer contract, which is the +part of that call an application cannot avoid and a Driver Manager does not +implement: how much is written, what the indicator counts, that a zero-length +call is the documented length probe rather than a completed read, that a +second call continues the value instead of restarting it — a driver that +restarts turns the documented drain loop into an infinite one — and that an +ordinal past the last column is `07009` rather than a general error. A DSN run +would reach the same code by a longer route, so there is only one. Because the spec's **(DM)** diagnostics come from the Driver Manager, that suite never demands one. Where a SQLSTATE is (DM)-annotated it asserts what the driver diff --git a/integration-tests/suites/test_c_abi.py b/integration-tests/suites/test_c_abi.py index 9607edf..9dcb431 100644 --- a/integration-tests/suites/test_c_abi.py +++ b/integration-tests/suites/test_c_abi.py @@ -18,8 +18,9 @@ Covers: handle lifecycle and parentage, invalid and stale handles, double free, use after free, connection state, cursor state, prepare / execute / re-execute, SQLFreeStmt options, statement and connection attribute round-trips, the -enforced query timeout, transactions including DDL, and the catalog functions -SQLite answers with no rows. +enforced query timeout, transactions including DDL, the catalog functions +SQLite answers with no rows, and the SQLGetData buffer contract — truncation, +the zero-length length probe, chunked retrieval, and the ordinal range check. Usage: python3 integration-tests/suites/test_c_abi.py \ @@ -98,6 +99,7 @@ SQL_TC_ALL = 2 SQL_C_CHAR = 1 +SQL_C_WCHAR = -8 SQL_C_SBIGINT = -25 SQL_BIGINT = -5 @@ -863,6 +865,165 @@ def main(): lib.SQLCloseCursor(param_stmt) lib.SQLFreeHandle(SQL_HANDLE_STMT, param_stmt) + # --------------------------------------------------------------- + print("\n--- SQLGetData buffer semantics ---") + # The buffer contract is the part of SQLGetData an application cannot avoid + # and a Driver Manager does not implement: how much is written, what the + # indicator counts, and whether a second call continues or restarts. A + # driver that restarts turns the documented drain loop into an infinite one, + # which no amount of correct data can compensate for. + # + # `get_stmt` is its own handle rather than the shared `stmt`, which is + # carrying cursor state the sections above still assert on. + get_stmt = P() + lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(get_stmt)) + + ALPHABET = b"abcdefghijklmnopqrstuvwxyz" + + def positioned(sql): + """`sql` executed and the cursor on its first row.""" + lib.SQLCloseCursor(get_stmt) + text, _keep = w(sql) + lib.SQLExecDirectW(get_stmt, text, SQL_NTS) + lib.SQLFetch(get_stmt) + + def get_char(nbytes, guard=0xAA): + """SQLGetData into a poisoned buffer: (rc, bytes, indicator). + + The buffer is filled with `guard` first and is larger than `nbytes`, so + a write past the length the driver was given is visible rather than + landing in memory that happened to be zero. + """ + buf = ctypes.create_string_buffer(max(nbytes, 1) + 32) + ctypes.memset(buf, guard, len(buf)) + ind = ctypes.c_int64(-999) + r = lib.SQLGetData( + get_stmt, 1, SQL_C_CHAR, ctypes.cast(buf, P), nbytes, ctypes.byref(ind) + ) + return r, bytes(buf), ind.value + + positioned("SELECT 'hello'") + r, raw, ind = get_char(6) + check("a buffer that exactly fits is plain SUCCESS", r, SQL_SUCCESS) + check( + "the exactly-fitting buffer holds the value and its terminator", + SQL_SUCCESS if raw[:6] == b"hello\x00" else SQL_ERROR, + SQL_SUCCESS, + got_state=f"wrote {raw[:8]!r}", + ) + + positioned("SELECT 'hello'") + r, raw, ind = get_char(5) + check( + "one byte short truncates", + r, + SQL_SUCCESS_WITH_INFO, + state="01004", + got_state=sqlstate(lib, SQL_HANDLE_STMT, get_stmt), + ) + check( + "the truncated buffer is still terminated", + SQL_SUCCESS if raw[:5] == b"hell\x00" else SQL_ERROR, + SQL_SUCCESS, + got_state=f"wrote {raw[:8]!r}", + ) + check( + "the indicator reports the untruncated length", + SQL_SUCCESS if ind == 5 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"indicator {ind}, expected 5", + ) + + positioned("SELECT 'hello'") + r, raw, ind = get_char(0) + # The documented probe-then-fetch idiom: size the buffer with a zero-length + # call, then allocate and read. A driver that treats this as a completed + # read leaves the second call with nothing to return. + check( + "a zero-length call reports the length", + SQL_SUCCESS if ind == 5 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"indicator {ind}, expected 5", + ) + check( + "a zero-length call writes nothing", + SQL_SUCCESS if raw[0] == 0xAA else SQL_ERROR, + SQL_SUCCESS, + got_state=f"first byte {raw[0]:#04x}, expected the untouched guard", + ) + + positioned("SELECT 'hello'") + r, _raw, _ind = get_char(-1) + check( + "a negative buffer length is refused", + r, + SQL_ERROR, + state="HY090", + got_state=sqlstate(lib, SQL_HANDLE_STMT, get_stmt), + ) + + # Chunked retrieval. Ten-byte buffers hold nine characters each, so 26 + # characters take three calls and a fourth reports the column exhausted. + positioned(f"SELECT '{ALPHABET.decode()}'") + chunks, codes = [], [] + for _ in range(8): + r, raw, ind = get_char(10) + codes.append(r) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + break + chunks.append(raw[: raw.index(b"\x00")]) + check( + "successive SQLGetData calls continue the value rather than restarting", + SQL_SUCCESS if b"".join(chunks) == ALPHABET else SQL_ERROR, + SQL_SUCCESS, + got_state=f"reassembled {b''.join(chunks)!r}", + ) + check( + "the drain loop terminates with NO_DATA", + codes[-1] if codes else SQL_ERROR, + SQL_NO_DATA, + ) + + # A wide read counts its indicator in bytes, not characters. Getting this + # wrong halves or doubles every buffer an application sizes from it. + positioned("SELECT 'abcdefghij'") + wbuf_ = (ctypes.c_uint16 * 64)() + wind = ctypes.c_int64(-999) + r = lib.SQLGetData( + get_stmt, 1, SQL_C_WCHAR, ctypes.cast(wbuf_, P), 8, ctypes.byref(wind) + ) + check( + "a wide indicator is counted in bytes", + SQL_SUCCESS if wind.value == 20 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"indicator {wind.value}, expected 20 for 10 characters", + ) + + # Spec (SQLGetData, Diagnostics), 07009: "the value specified for the + # argument Col_or_Param_Num was greater than the number of columns in the + # result set". That clause carries no (DM) marker, so it is the driver's. + positioned("SELECT 1, 2") + over = ctypes.c_int64(0) + oind = ctypes.c_int64(0) + r = lib.SQLGetData( + get_stmt, + 3, + SQL_C_SBIGINT, + ctypes.cast(ctypes.byref(over), P), + 8, + ctypes.byref(oind), + ) + check( + "a column past the last is an invalid descriptor index", + r, + SQL_ERROR, + state="07009", + got_state=sqlstate(lib, SQL_HANDLE_STMT, get_stmt), + ) + + lib.SQLCloseCursor(get_stmt) + lib.SQLFreeHandle(SQL_HANDLE_STMT, get_stmt) + # --------------------------------------------------------------- print("\n--- cancel and teardown ---") # "A call to SQLCancel when no processing is being done on the statement diff --git a/integration-tests/suites/test_sql_surface.py b/integration-tests/suites/test_sql_surface.py index 7502e57..6896051 100644 --- a/integration-tests/suites/test_sql_surface.py +++ b/integration-tests/suites/test_sql_surface.py @@ -17,7 +17,17 @@ `{oj ...}` are translated by `escape_dialect.rs` into what SQLite spells them as, and three of them (`CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`) are bare keywords that a name swap alone cannot - produce. Nothing else in the suite exercises that module. + produce. Nothing else in the suite exercises that module. Both directions + are asserted: that the rewriter fires, and that it stops at string + literals, comments and all three identifier-quoting styles, which is the + half that corrupts data rather than merely failing. + - **The scalar-function bitmaps as a contract.** Every bit set in + `SQL_STRING_FUNCTIONS` and its three siblings is read back from + `SQLGetInfo` and the matching `{fn NAME(...)}` executed, which is the only + thing tying `info.rs`'s bitmaps to `escape_dialect.rs`'s remap table. + - **Catalog arguments as untrusted input.** The catalog functions are the + only path in the driver that builds SQL out of a caller-supplied argument, + and for a BI tool that argument often came from a filter box. - **Keys and indexes that are really there.** Trino publishes no primary key, foreign key or index metadata, so its suite can only assert that those calls return an empty set without erroring. SQLite has all three, so the @@ -51,6 +61,12 @@ PARENT = "sqlsurf_parent" CHILD = "sqlsurf_child" +# A table whose name carries the character that ends a SQL string literal. The +# catalog functions take it as a *value*, so it can only work if they bind their +# arguments; a driver that interpolates them into the query text produces a +# syntax error here, or worse, runs whatever follows the quote. +QUOTED = "sqlsurf_quo'te" + def make_fixture(cur): """Two related tables, so the key and index calls have something to find. @@ -58,9 +74,14 @@ def make_fixture(cur): Dropped in reverse order: `sqlsurf_child` holds the foreign key, and the driver turns foreign-key enforcement on for every connection, so dropping the parent first would be refused. + + `QUOTED` stands apart from the pair: it takes part in no relationship and + exists only so the catalog probes have a hostile name to look up. """ cur.execute(f"DROP TABLE IF EXISTS {CHILD}") cur.execute(f"DROP TABLE IF EXISTS {PARENT}") + cur.execute(f'DROP TABLE IF EXISTS "{QUOTED}"') + cur.execute(f'CREATE TABLE "{QUOTED}" (id INTEGER PRIMARY KEY, label TEXT)') cur.execute(f"CREATE TABLE {PARENT} (id INTEGER PRIMARY KEY, name TEXT NOT NULL)") cur.execute( f"CREATE TABLE {CHILD} (" @@ -78,6 +99,7 @@ def drop_fixture(cur): try: cur.execute(f"DROP TABLE IF EXISTS {CHILD}") cur.execute(f"DROP TABLE IF EXISTS {PARENT}") + cur.execute(f'DROP TABLE IF EXISTS "{QUOTED}"') except Exception: # noqa: BLE001 pass @@ -314,6 +336,150 @@ def refused(sql, near): "WITH a(x) AS (VALUES (1),(2),(3)), b(y) AS (VALUES (2)) " "SELECT count(*) FROM {oj a LEFT OUTER JOIN b ON a.x = b.y}", 3)) + # -------------------------------------------------------------- + print("\n--- what escape translation must NOT touch ---") + # The section above proves the rewriter fires. These prove it stops at + # the boundaries, which is the half that corrupts data rather than + # merely failing: a rewrite inside a string literal changes the value a + # query returns, silently and with no error anywhere. Each of these + # asserts the braces survive verbatim. + R.run("a {fn ...} inside a string literal is data, not an escape", + lambda: scalar("SELECT '{fn UCASE(x)}'", "{fn UCASE(x)}")) + R.run("a doubled quote keeps the literal open", lambda: scalar( + "SELECT 'it''s {fn UCASE(x)}'", "it's {fn UCASE(x)}")) + R.run("a lone brace inside a literal opens nothing", + lambda: scalar("SELECT 'a{b'", "a{b")) + R.run("a closing brace inside a literal closes nothing", + lambda: scalar("SELECT 'c}d'", "c}d")) + # All three of SQLite's identifier-quoting styles are declared by the + # dialect, so all three have to be honoured as quoting. + R.run('a "double-quoted" identifier is not rewritten', lambda: scalar( + 'SELECT "{fn UCASE(x)}" FROM (SELECT 1 AS "{fn UCASE(x)}")', 1)) + R.run("a [bracketed] identifier is not rewritten", lambda: scalar( + "SELECT [{fn UCASE(x)}] FROM (SELECT 1 AS [{fn UCASE(x)}])", 1)) + R.run("a `backticked` identifier is not rewritten", lambda: scalar( + "SELECT `{fn UCASE(x)}` FROM (SELECT 1 AS `{fn UCASE(x)}`)", 1)) + # SQLite accepts both comment forms, and an escape inside one is text. + R.run("an escape inside a line comment is left alone", lambda: scalar( + "SELECT 'v' -- {fn UCASE(x)}\n", "v")) + R.run("an escape inside a block comment is left alone", lambda: scalar( + "SELECT /* {fn UCASE(x)} */ 'v'", "v")) + # Nesting is the case a single-pass rewriter gets wrong. + R.run("nested {fn ...} escapes both translate", + lambda: scalar("SELECT {fn UCASE({fn LCASE('AbC')})}", "ABC")) + + # -------------------------------------------------------------- + print("\n--- every advertised scalar function actually works ---") + # A SQL_*_FUNCTIONS bit is a promise: a BI tool emits {fn NAME(...)} + # only for the bits the driver sets. A set bit whose escape does not + # execute is a query the tool will build and the driver will reject, + # and nothing else in the suite ties `info.rs`'s bitmaps to + # `escape_dialect.rs`'s remap table. The calls use spec-shaped + # arguments, so a name that maps to a SQLite function with a different + # signature fails here rather than in a customer's dashboard. + SQL_NUMERIC_FUNCTIONS = 49 + SQL_STRING_FUNCTIONS = 50 + SQL_SYSTEM_FUNCTIONS = 51 + SQL_TIMEDATE_FUNCTIONS = 52 + ADVERTISED = [ + ("string", SQL_STRING_FUNCTIONS, [ + (0x00000001, "CONCAT", "{fn CONCAT('a','b')}"), + (0x00000002, "INSERT", "{fn INSERT('abcdef',2,3,'xyz')}"), + (0x00000004, "LEFT", "{fn LEFT('abcdef',2)}"), + (0x00000008, "LTRIM", "{fn LTRIM(' ab')}"), + (0x00000010, "LENGTH", "{fn LENGTH('abc')}"), + (0x00000020, "LOCATE", "{fn LOCATE('b','abc')}"), + (0x00000040, "LCASE", "{fn LCASE('AB')}"), + (0x00000080, "REPEAT", "{fn REPEAT('a',3)}"), + (0x00000100, "REPLACE", "{fn REPLACE('abc','b','x')}"), + (0x00000200, "RIGHT", "{fn RIGHT('abcdef',2)}"), + (0x00000400, "RTRIM", "{fn RTRIM('ab ')}"), + (0x00000800, "SUBSTRING", "{fn SUBSTRING('abcdef',2,3)}"), + (0x00001000, "UCASE", "{fn UCASE('ab')}"), + (0x00002000, "ASCII", "{fn ASCII('a')}"), + (0x00004000, "CHAR", "{fn CHAR(65)}"), + (0x00008000, "DIFFERENCE", "{fn DIFFERENCE('a','b')}"), + (0x00010000, "LOCATE_2", "{fn LOCATE('b','abcb',3)}"), + (0x00020000, "SOUNDEX", "{fn SOUNDEX('Robert')}"), + (0x00040000, "SPACE", "{fn SPACE(3)}"), + (0x00080000, "BIT_LENGTH", "{fn BIT_LENGTH('abc')}"), + (0x00100000, "CHAR_LENGTH", "{fn CHAR_LENGTH('abc')}"), + (0x00200000, "CHARACTER_LENGTH", "{fn CHARACTER_LENGTH('abc')}"), + (0x00400000, "OCTET_LENGTH", "{fn OCTET_LENGTH('abc')}"), + (0x00800000, "POSITION", "{fn POSITION('b','abc')}"), + ]), + ("numeric", SQL_NUMERIC_FUNCTIONS, [ + (0x00000001, "ABS", "{fn ABS(-1)}"), + (0x00000002, "ACOS", "{fn ACOS(0.5)}"), + (0x00000004, "ASIN", "{fn ASIN(0.5)}"), + (0x00000008, "ATAN", "{fn ATAN(0.5)}"), + (0x00000010, "ATAN2", "{fn ATAN2(1,1)}"), + (0x00000020, "CEILING", "{fn CEILING(1.2)}"), + (0x00000040, "COS", "{fn COS(1)}"), + (0x00000080, "COT", "{fn COT(1)}"), + (0x00000100, "EXP", "{fn EXP(1)}"), + (0x00000200, "FLOOR", "{fn FLOOR(1.7)}"), + (0x00000400, "LOG", "{fn LOG(2)}"), + (0x00000800, "MOD", "{fn MOD(7,2)}"), + (0x00001000, "SIGN", "{fn SIGN(-3)}"), + (0x00002000, "SIN", "{fn SIN(1)}"), + (0x00004000, "SQRT", "{fn SQRT(4)}"), + (0x00008000, "TAN", "{fn TAN(1)}"), + (0x00010000, "PI", "{fn PI()}"), + (0x00020000, "RAND", "{fn RAND()}"), + (0x00040000, "DEGREES", "{fn DEGREES(1)}"), + (0x00080000, "LOG10", "{fn LOG10(10)}"), + (0x00100000, "POWER", "{fn POWER(2,3)}"), + (0x00200000, "RADIANS", "{fn RADIANS(90)}"), + (0x00400000, "ROUND", "{fn ROUND(1.55,1)}"), + (0x00800000, "TRUNCATE", "{fn TRUNCATE(1.55,1)}"), + ]), + ("timedate", SQL_TIMEDATE_FUNCTIONS, [ + (0x00000001, "NOW", "{fn NOW()}"), + (0x00000002, "CURDATE", "{fn CURDATE()}"), + (0x00000004, "DAYOFMONTH", "{fn DAYOFMONTH({d '2020-01-02'})}"), + (0x00000008, "DAYOFWEEK", "{fn DAYOFWEEK({d '2020-01-02'})}"), + (0x00000010, "DAYOFYEAR", "{fn DAYOFYEAR({d '2020-01-02'})}"), + (0x00000020, "MONTH", "{fn MONTH({d '2020-01-02'})}"), + (0x00000040, "QUARTER", "{fn QUARTER({d '2020-01-02'})}"), + (0x00000080, "WEEK", "{fn WEEK({d '2020-01-02'})}"), + (0x00000100, "YEAR", "{fn YEAR({d '2020-01-02'})}"), + (0x00000200, "CURTIME", "{fn CURTIME()}"), + (0x00000400, "HOUR", "{fn HOUR({t '10:00:00'})}"), + (0x00000800, "MINUTE", "{fn MINUTE({t '10:00:00'})}"), + (0x00001000, "SECOND", "{fn SECOND({t '10:00:00'})}"), + (0x00002000, "TIMESTAMPADD", + "{fn TIMESTAMPADD(SQL_TSI_DAY,1,{d '2020-01-02'})}"), + (0x00004000, "TIMESTAMPDIFF", + "{fn TIMESTAMPDIFF(SQL_TSI_DAY,{d '2020-01-02'},{d '2020-01-03'})}"), + (0x00008000, "DAYNAME", "{fn DAYNAME({d '2020-01-02'})}"), + (0x00010000, "MONTHNAME", "{fn MONTHNAME({d '2020-01-02'})}"), + (0x00020000, "CURRENT_DATE", "{fn CURRENT_DATE()}"), + (0x00040000, "CURRENT_TIME", "{fn CURRENT_TIME()}"), + (0x00080000, "CURRENT_TIMESTAMP", "{fn CURRENT_TIMESTAMP()}"), + (0x00100000, "EXTRACT", "{fn EXTRACT(YEAR FROM {d '2020-01-02'})}"), + ]), + ("system", SQL_SYSTEM_FUNCTIONS, [ + (0x00000001, "USERNAME", "{fn USERNAME()}"), + (0x00000002, "DBNAME", "{fn DBNAME()}"), + (0x00000004, "IFNULL", "{fn IFNULL(NULL,1)}"), + ]), + ] + + def executes(sql): + cur.execute(f"SELECT {sql}").fetchall() + + for group, info_type, table in ADVERTISED: + bitmap = conn.getinfo(info_type) + claimed = [(n, s) for bit, n, s in table if bitmap & bit] + # An empty bitmap would make every assertion below vacuous, so the + # count is asserted rather than assumed. + R.check(f"SQL_{group.upper()}_FUNCTIONS advertises something", + claimed, f"{bitmap:#010x} claims {len(claimed)}") + for name, sql in claimed: + R.run(f"advertised {group} {{fn {name}}} executes", + lambda s=sql: executes(s)) + # -------------------------------------------------------------- print("\n--- ODBC catalog functions ---") R.run("SQLTables", lambda: ( @@ -382,6 +548,66 @@ def special_columns_name_a_row_identifier(): R.run("SQLProcedureColumns (empty is correct)", lambda: cur.procedureColumns().fetchall()) + # -------------------------------------------------------------- + print("\n--- catalog arguments are values, not SQL ---") + # Every catalog function takes caller-supplied names and patterns and + # turns them into a query against SQLite's schema. Those arguments reach + # the driver from wherever the application got them, which for a BI tool + # is often a user-typed filter box, so they are untrusted input on the + # only path in the driver that builds SQL from an argument at all. + + def quoted_table_is_found(): + found = cur.tables(table=QUOTED).fetchall() + # TABLE_NAME is column 3 of the SQLTables result set. + names = [r[2] for r in found] + assert QUOTED in names, f"expected {QUOTED!r}, got {names!r}" + + R.run("a table name containing a quote is matched", quoted_table_is_found) + R.run("SQLColumns on a quote-named table names its columns", lambda: ( + {r[3] for r in cur.columns(table=QUOTED).fetchall()} == {"id", "label"} + or (_ for _ in ()).throw(AssertionError("columns not reported")))) + R.run("SQLPrimaryKeys on a quote-named table", lambda: ( + cur.primaryKeys(table=QUOTED).fetchall() + or (_ for _ in ()).throw(AssertionError("no primary key reported")))) + + # The payload closes a literal and then issues a statement. It must be + # treated as a name that matches nothing. Asserting only "no exception" + # would pass for a driver that ran it and reported success, so the + # fixture is re-counted afterwards: still standing is the real check. + INJECTIONS = [ + "x'; DROP TABLE " + PARENT + "; --", + "'||(SELECT 1)||'", + 'x"; DROP TABLE ' + PARENT + "; --", + "%'; DROP TABLE " + PARENT + "; --", + ] + + def catalog_calls_survive(payload): + def go(): + # Each of these builds its own schema query from the argument. + cur.tables(table=payload).fetchall() + cur.columns(table=payload).fetchall() + cur.primaryKeys(table=payload).fetchall() + cur.foreignKeys(foreignTable=payload).fetchall() + cur.statistics(table=payload).fetchall() + cur.rowIdColumns(table=payload).fetchall() + # The fixture must still be there. `scalar` would raise a + # pyodbc error rather than return if the table were gone. + scalar(f"SELECT count(*) FROM {PARENT}", 2) + return go + + for payload in INJECTIONS: + R.run(f"catalog functions treat {payload!r} as a name", + catalog_calls_survive(payload)) + + # The pattern metacharacters have to keep working as patterns, or the + # check above could be satisfied by escaping everything indiscriminately. + R.run("'%' still matches every table", lambda: ( + len(cur.tables(table="%").fetchall()) >= 3 + or (_ for _ in ()).throw(AssertionError("'%' matched almost nothing")))) + R.run("'_' still matches a single character", lambda: ( + {r[2] for r in cur.tables(table="sqlsurf_paren_").fetchall()} == {PARENT} + or (_ for _ in ()).throw(AssertionError("'_' did not match")))) + # -------------------------------------------------------------- print("\n--- ordering, distinct, and null handling ---") R.run("ORDER BY on an unselected column", lambda: rows( From 3cabe60166c8f5c49ad402b7be80e400b9a30027 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 22:17:48 +0200 Subject: [PATCH 45/50] chore(deps): move stackable-odbc-core to d840a39 Nine commits, none of which touch `src/backend.rs`: the `Backend` and `StatementBackend` traits, their signatures, their defaults and the capability hooks are unchanged, so this driver adopts nothing new and overrides nothing it did not already. What changes sits under the FFI boundary. `SQLGetData` now converts a chunkable value once and drains it across calls instead of re-materialising it per call, taking an N-byte column read through a K-byte buffer from O(N^2/K) to O(N); `sql_fetch` builds its binding list once, in column order; and the string fetch conversions stop allocating per value. Here that is the `TEXT` and `BLOB` path, where an application reading a large value through whatever buffer size a driver manager picks paid the amplification. The rest is test and documentation work in core: misaligned-buffer coverage for every marshalling family, error-path tests asserting the SQLSTATE their names claim, and the `Backend::connect` failure path. `cargo test` (296 passed) and `pre-commit run --all-files` (all sixteen hooks) pass on the new pin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 161ec47..7ef5e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "stackable-odbc-core" version = "0.0.1" -source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#afad4f67e45402142b58ef13b4455fddc2a69dfe" +source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#d840a3955e03a406c1e54638bda0e0062b04d3c0" dependencies = [ "odbc-sys", "snafu", From d17d45d3033c331e6cce7e6c6ba15dd4beb90746 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Mon, 3 Aug 2026 22:18:21 +0200 Subject: [PATCH 46/50] fix(ci): grade the export check on what the DLL exports "Verify DLL exports" could not fail. It piped a symbol count into `xargs -I{} echo`, and a step's exit status is the last command in the pipeline, so the grade came from `xargs`, which returns 0 whatever it is handed. The job's own comment claims it "checks ... that the DLL exports the ODBC entry points"; measured against an empty file standing in for a DLL that exports nothing, the old form printed "SQLite DLL: 0 ODBC symbols exported" and exited 0. That matters because this is the only check on the artifact users load. A `forward_ffi!` regression, a linker script change or a build.rs edit that stopped exporting the entry points would ship, and the first symptom is the driver failing to load with no diagnosis, which is what README.md's troubleshooting section already fields. It now reads the export address table and asserts 23 named entry points -- the ones an application actually reaches the driver through, plus `ConfigDSNW`, which the ODBC Administrator's "Configure..." button needs -- and a floor of 55, so a wholesale regression is caught even if those particular names survive. Measured locally: the DLL exports 61 and the Linux .so 60, the difference being `ConfigDSNW`, which is `#[cfg(windows)]`. That matches AGENTS.md's "60 `SQL*` functions, plus `ConfigDSNW` on Windows". Verified in both directions before landing: exit 1 against a DLL exporting nothing, exit 0 against the real cross-built DLL. The same defect and the same fix are in stackable-odbc-trino, where a pre-release review found it first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/build.yaml | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 30c5f91..729134c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -188,9 +188,43 @@ jobs: - name: Build Windows DLL run: cargo build --locked --target x86_64-pc-windows-gnu --release + # Graded on what the DLL actually exports, not on the last command in a + # pipeline. The previous form piped a symbol count into `xargs echo`, and + # the step's exit status was `xargs`'s, which is 0 whatever it echoes: a + # DLL exporting nothing at all printed "0 ODBC symbols exported" and + # passed. The named set catches the entry points an application reaches + # the driver through, and the floor catches a wholesale regression in + # core's `forward_ffi!` even if these particular names survive. - name: Verify DLL exports run: | - x86_64-w64-mingw32-objdump -p target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll | grep -c "SQL" | xargs -I{} echo "SQLite DLL: {} ODBC symbols exported" + DLL=target/x86_64-pc-windows-gnu/release/stackable_odbc_sqlite.dll + EXPORTS=$(x86_64-w64-mingw32-objdump -p "$DLL" \ + | awk '/Export Address Table/,/Ordinal base/' \ + | grep -oE '\b(SQL|Config)[A-Za-z]+\b' | sort -u) + echo "$EXPORTS" | tr '\n' ' '; echo + + missing="" + for sym in SQLAllocHandle SQLFreeHandle SQLDriverConnectW SQLConnectW \ + SQLBrowseConnectW SQLDisconnect SQLPrepareW SQLExecute \ + SQLExecDirectW SQLBindParameter SQLDescribeParam SQLFetch \ + SQLGetData SQLNumResultCols SQLDescribeColW SQLGetInfoW \ + SQLGetTypeInfoW SQLGetDiagRecW SQLTablesW SQLColumnsW \ + SQLEndTran SQLCancel ConfigDSNW; do + grep -qx "$sym" <<< "$EXPORTS" || missing="$missing $sym" + done + if [ -n "$missing" ]; then + echo "::error::the DLL does not export:$missing" + exit 1 + fi + + # The DLL exports 61 and the Linux .so 60, the difference being + # ConfigDSNW, which is `#[cfg(windows)]`. + count=$(echo "$EXPORTS" | grep -c .) + if [ "$count" -lt 55 ]; then + echo "::error::only $count ODBC symbols exported; expected at least 55" + exit 1 + fi + echo "SQLite DLL: $count ODBC symbols exported, all required names present" # build.rs embeds this, and it is what stops the ODBC Data Source # Administrator listing the driver as "Not marked". A cross-build with no From 1333439b5abef7c98b971b0019ea009824e882fb Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Tue, 4 Aug 2026 13:28:38 +0200 Subject: [PATCH 47/50] chore(deps): move stackable-odbc-core to fa350c4 Twenty-four commits. One touches `src/backend.rs`: `StatementBackend::take_value_warning`, defaulted to `None` and drained by core after every `get_data`, through which a backend raises `01S07` for fractional precision it dropped inside its own type conversion, before a `ColumnValue` existed and so where core cannot see it. This driver adopts nothing there, because it never drops one. A `TEXT` datetime reaches core as `ColumnValue::String` and core parses it; the `INTEGER` epoch-seconds encoding has no sub-second part to lose; and `decode_julian_day` rounds an `f64` to the nearest nanosecond, which is the closest representation of a value that never held finer precision rather than a digit discarded. The declared-versus-delivered gap this driver does have runs the other way: `MAX_FRACTIONAL_SECONDS_PRECISION` reports 3 while the Julian-day path can deliver 9, and `01S07` names the opposite case. Where core drops a fraction itself, a `ColumnValue::Time` fraction written to `SQL_C_TYPE_TIME` or a fraction lost reaching an exact-integer C type, core raises the record, and the new method's doc is explicit that a backend must not report it a second time. The change that does reach this driver is `SQLGetData` range-checking the column ordinal in core, against `StatementBackend::column_count`. `SqliteStatement::get_data` answers `07009` for the same condition and is unchanged, but core now catches it first, so that arm is no longer what an application sees. Two doc comments said core delegated the check and are corrected; the arm itself stays, so a direct backend call and the FFI path cannot disagree about what a column that does not exist is called. `get_data_column_past_the_last_is_invalid_descriptor_index` passes either way, which is what makes it worth keeping. The rest is inert here. `SQL_C_GUID` and `SQL_C_NUMERIC` as retrieval targets, the SQL-to-C interval tables and `SQL_C_DEFAULT` selecting `SQL_C_GUID` all need a declared type this driver never produces: `sqlite_type_to_sql_data_type` maps `NUMERIC` to `DECIMAL` and has no GUID or interval arm at all. The parameter fixes (`HY090` for an undefined negative indicator, refusing a C type core cannot marshal rather than binding NULL), `SQLColAttributeW`'s `HY091`, the ODBC 2.x datetime codes in a descriptor and the float display size are all core's FFI layer, below the `Backend` boundary. `pre-commit run --all-files` passes all sixteen hooks (296 unit tests). Both integration suites pass on the new pin: 769 assertions through unixODBC on Linux, and the same 769 through the real Windows Driver Manager in the VM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- Cargo.lock | 2 +- src/backend/execute.rs | 12 ++++++------ src/ffi_integration_tests.rs | 12 +++++++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ef5e12..a7b1982 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "stackable-odbc-core" version = "0.0.1" -source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#d840a3955e03a406c1e54638bda0e0062b04d3c0" +source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#fa350c43a1cb52937954e91e708912e40eafa505" dependencies = [ "odbc-sys", "snafu", diff --git a/src/backend/execute.rs b/src/backend/execute.rs index 227c96d..3679caf 100644 --- a/src/backend/execute.rs +++ b/src/backend/execute.rs @@ -396,12 +396,12 @@ impl StatementBackend for SqliteStatement { /// number of columns in the result set" is `07009`, and that clause of the /// row carries no **(DM)** marker, so it is this driver's to return. /// - /// Core reaches the backend for the range check rather than doing it - /// itself, so `SqlState::general_error()` here was what an application - /// actually saw for an out-of-range ordinal: `HY000`, which says nothing - /// about which argument was wrong. The column-0 arm is unreachable through - /// `SQLGetData` — core rejects the bookmark ordinal before calling — but it - /// answers `07009` too, so the two ways of naming a column that does not + /// Core range-checks the ordinal against `StatementBackend::column_count` + /// before reaching the backend, so neither arm below is what an application + /// sees through `SQLGetData` any more: the out-of-range arm is the answer + /// for a direct backend call, and the column-0 arm was already unreachable + /// because core rejects the bookmark ordinal before calling. Both are kept, + /// and both answer `07009`, so the ways of naming a column that does not /// exist cannot disagree depending on which layer caught it. fn get_data( &mut self, diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs index de363a6..a67a8c4 100644 --- a/src/ffi_integration_tests.rs +++ b/src/ffi_integration_tests.rs @@ -3063,11 +3063,13 @@ fn get_data_truncates_string_returns_success_with_info() { /// clause carries no **(DM)** marker, so the Driver Manager does not supply it /// and the driver has to. /// -/// Core does not range-check the ordinal itself — it asks the backend and -/// reports whatever SQLSTATE comes back — so this is `SqliteStatement::get_data` -/// being asserted through the entry point an application actually calls. It -/// answered `HY000` until the ordinal check was given its own SQLSTATE, which -/// told an application only that *something* went wrong. +/// Core range-checks the ordinal against `column_count` before reaching the +/// backend, and `SqliteStatement::get_data` answers `07009` for the same +/// condition, so this asserts the SQLSTATE an application sees whichever layer +/// caught it. It was written when only the driver checked, and the driver +/// answered `HY000`, which told an application only that *something* went +/// wrong. What it pins is worth pinning from the entry point an application +/// actually calls either way. /// /// Both ends of the range are probed. One past the last column is the case that /// regressed; `u16::MAX` is the same condition reached by a wildly wrong From 6893086f5da91534c0ede3ddd798bcdc11191230 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Tue, 4 Aug 2026 13:28:50 +0200 Subject: [PATCH 48/50] docs: punctuate the parenthetical asides without em dashes An em dash pair breaks the reading flow of a sentence that a comma or a real parenthesis carries without the interruption. Four passages, none of which change what they say: - `AGENTS.md`, the `HY008` clause, to a comma. - `integration-tests/README.md`, the hostile-catalog-argument aside and the drain-loop aside, both to parentheses, which is what the second pair was doing anyway. - `test_c_abi.py`, where the dash introduced the list of what the `SQLGetData` buffer contract covers. Parentheses, because the sentence already opens with "Covers:" and a second colon would read as a second list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 2 +- integration-tests/README.md | 8 ++++---- integration-tests/suites/test_c_abi.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1223a8..1fd0d78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,7 +358,7 @@ differently on purpose. The interrupt handle is the *connection's*, cloned from `cancelled` flag is the *token's own*, minted fresh by `cancel_token`. Core mints a token per statement-producing call, so a flag shared across them would leave a cancelled statement permanently unusable, with every later error on the -connection reported as `HY008` — where the spec says "After the statement has +connection reported as `HY008`, where the spec says "After the statement has been canceled, the application can call SQLExecute or SQLExecDirect again." This is the **aliasing** token shape of the two `Backend::CancelToken`'s doc diff --git a/integration-tests/README.md b/integration-tests/README.md index 25c2b34..0517e98 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -84,9 +84,9 @@ Last in that suite, the catalog functions are given hostile names. They are the only path in the driver that turns a caller-supplied argument into SQL, and for a BI tool that argument is often typed into a filter box. A table whose name contains a quote has to be found, payloads that close a literal and issue a -`DROP` have to be treated as names that match nothing — asserted by re-counting +`DROP` have to be treated as names that match nothing (asserted by re-counting the fixture afterwards, since "no exception" would also pass for a driver that -ran them — and `%` and `_` have to keep working as patterns, which rules out +ran them), and `%` and `_` have to keep working as patterns, which rules out escaping everything indiscriminately. Then `test_c_abi.py`, once. It loads the driver's `.so` with `ctypes` and calls @@ -99,8 +99,8 @@ transactions. It also covers the `SQLGetData` buffer contract, which is the part of that call an application cannot avoid and a Driver Manager does not implement: how much is written, what the indicator counts, that a zero-length call is the documented length probe rather than a completed read, that a -second call continues the value instead of restarting it — a driver that -restarts turns the documented drain loop into an infinite one — and that an +second call continues the value instead of restarting it (a driver that +restarts turns the documented drain loop into an infinite one), and that an ordinal past the last column is `07009` rather than a general error. A DSN run would reach the same code by a longer route, so there is only one. diff --git a/integration-tests/suites/test_c_abi.py b/integration-tests/suites/test_c_abi.py index 9dcb431..aa74f58 100644 --- a/integration-tests/suites/test_c_abi.py +++ b/integration-tests/suites/test_c_abi.py @@ -19,8 +19,8 @@ use after free, connection state, cursor state, prepare / execute / re-execute, SQLFreeStmt options, statement and connection attribute round-trips, the enforced query timeout, transactions including DDL, the catalog functions -SQLite answers with no rows, and the SQLGetData buffer contract — truncation, -the zero-length length probe, chunked retrieval, and the ordinal range check. +SQLite answers with no rows, and the SQLGetData buffer contract (truncation, +the zero-length length probe, chunked retrieval, and the ordinal range check). Usage: python3 integration-tests/suites/test_c_abi.py \ From 73157ff5444d735365f2e6fc38d10a9524623d70 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy <andrew.kenworthy@stackable.tech> Date: Tue, 4 Aug 2026 15:43:14 +0200 Subject: [PATCH 49/50] docs: updates to the README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69fb0d8..f752478 100644 --- a/README.md +++ b/README.md @@ -174,11 +174,11 @@ faked, so the tool can react instead of trusting a wrong answer. - **One isolation level.** SQLite gives you serializable transactions, so that is the only level offered, and asking for a weaker one is refused up front. - **No setup dialog on Linux.** unixODBC has no convention for a driver to put - a window on the screen, so a DSN there is a section in `odbc.ini`. + a window on the screen, so on Linux a DSN is defined by a section in `odbc.ini`. ## Compatibility -| | | +| Component | Support | |---|---| | ODBC | 3.80 | | Platforms | Linux x86-64, Windows x86-64 | From a4a46f8cbe8b4eebd8610244476f4c7562fe8bf0 Mon Sep 17 00:00:00 2001 From: Malte Sander <malte.sander.it@gmail.com> Date: Tue, 4 Aug 2026 16:40:12 +0200 Subject: [PATCH 50/50] chore(deps): track stackable-odbc-core v0.1.0 Core is public now and carries a v0.1.0 tag, so pin the tag rather than the scaffolding branch. A tag resolves to one immutable commit, which a branch does not, and the SBOM's purl already names the resolved commit either way. Also drop the release.toml rule that rewrote the linux archive name in packaging/README.md. That file names archives with a `<version>` placeholder because it documents a naming scheme rather than one release, so the rule matched nothing and its `exactly = 1` aborted `cargo release` before it could tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 4 ++-- packaging/sbom.sh | 2 +- release.toml | 13 +++++++------ 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7b1982..74b79f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -116,18 +116,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstyle", "clap_lex", @@ -673,9 +673,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -858,8 +858,8 @@ dependencies = [ [[package]] name = "stackable-odbc-core" -version = "0.0.1" -source = "git+https://github.com/stackabletech/stackable-odbc-core.git?branch=scaffolding#fa350c43a1cb52937954e91e708912e40eafa505" +version = "0.1.0" +source = "git+https://github.com/stackabletech/stackable-odbc-core.git?tag=v0.1.0#23c924489e135d1d3da1d1664ae16bf8656d5aa3" dependencies = [ "odbc-sys", "snafu", @@ -953,9 +953,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", diff --git a/Cargo.toml b/Cargo.toml index b1b0360..e4bca41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ rusqlite = { version = "0.40", features = [ ] } serde_json = "1" snafu = "0.9" -stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", branch = "scaffolding" } +stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.0" } tracing = "0.1" [dev-dependencies] @@ -41,7 +41,7 @@ proptest = "1" # attach/detach helpers. Default-off there because it is test code that would # otherwise land in this driver's shipped binary; enabled only here, so # `cargo test` sees it and `cargo build` does not. -stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", branch = "scaffolding", features = ["test-support"] } +stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.0", features = ["test-support"] } [lints.clippy] unwrap_in_result = "deny" diff --git a/packaging/sbom.sh b/packaging/sbom.sh index 6b2045d..fdf4083 100755 --- a/packaging/sbom.sh +++ b/packaging/sbom.sh @@ -141,7 +141,7 @@ syft "$ARTIFACT" -o cyclonedx-json="$RAW" --quiet # --- enrich ---------------------------------------------------------------- # cargo-auditable embeds only name, version and source kind, so syft's output # carries no licenses, and a git or path dependency is indistinguishable from a -# crates.io package. A scanner resolving pkg:cargo/stackable-odbc-core@0.0.1 +# crates.io package. A scanner resolving pkg:cargo/stackable-odbc-core@0.1.0 # would reach a crates.io package that does not exist yet. # # Everything below keys off cargo metadata's source *kind*, never off a crate diff --git a/release.toml b/release.toml index a720cd8..62f2ef1 100644 --- a/release.toml +++ b/release.toml @@ -62,14 +62,15 @@ search = "\\[Unreleased\\]: https://github.com/stackabletech/stackable-odbc-sqli replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-sqlite/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-sqlite/releases/tag/v{{version}}" min = 0 +# The only release version literal in packaging/README.md is the `VERSION=` the +# example build command passes to build-archives.sh, so that the documented +# command reproduces the archives of the release the tree is at. The archive +# names the file lists elsewhere use a `<version>` placeholder, because they +# document a naming scheme rather than one release, and so need no rule. The +# remaining version literals there pin syft and cargo-auditable, which track +# their own upstreams and must survive a release untouched. [[pre-release-replacements]] file = "packaging/README.md" search = "VERSION=[0-9]+\\.[0-9]+\\.[0-9]+" replace = "VERSION={{version}}" exactly = 1 - -[[pre-release-replacements]] -file = "packaging/README.md" -search = "stackable-odbc-sqlite-[0-9]+\\.[0-9]+\\.[0-9]+-linux-x64\\.tar\\.gz" -replace = "stackable-odbc-sqlite-{{version}}-linux-x64.tar.gz" -exactly = 1