diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..debdc2c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - run: pip install ruff==0.16.3 + - run: ruff check . + - run: ruff format --check . + # rust-toolchain.toml is what picks the compiler, here and on + # every machine, so nothing installs one. + - run: cargo fmt --all --check + - run: cargo clippy --all-targets -- -D warnings + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.11", "3.14"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - uses: Swatinem/rust-cache@v2 + # Installed as a wheel rather than developed in place, so what + # the suite imports is what a person who runs `pip install zudb` + # gets: the extension out of a wheel, the package out of + # site-packages, and nothing resolved out of the checkout. + - run: pip install . + - run: pip install pytest + - run: pytest diff --git a/.gitignore b/.gitignore index e43b0f9..8ede5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,15 @@ .DS_Store +target/ +__pycache__/ +*.pyc +.venv/ +dist/ +.pytest_cache/ +.ruff_cache/ +.cargo/config.toml + +# The built extension, which maturin drops into the source tree when +# it develops the package in place. +*.so +*.pyd +*.dylib diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1aa6476 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1326 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +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 = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[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 = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[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 = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +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.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-core", + "futures-util", + "http", + "humantime", + "itertools", + "parking_lot", + "percent-encoding", + "thiserror", + "url", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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 = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +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 = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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 = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +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 = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "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 = "twox-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "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 = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zu" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", + "zu-encoding", + "zu-exec", + "zu-query", + "zu-s3", + "zu-sqlite", + "zu-storage", + "zu-vector", + "zu-zu1", +] + +[[package]] +name = "zu-common" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "thiserror", +] + +[[package]] +name = "zu-encoding" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "ruzstd", + "zu-common", +] + +[[package]] +name = "zu-exec" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", + "zu-query", + "zu-vector", +] + +[[package]] +name = "zu-query" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "crossbeam-deque", + "zu-common", + "zu-storage", + "zu-vector", +] + +[[package]] +name = "zu-s3" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "crc32c", + "object_store", + "zu-common", + "zu-storage", +] + +[[package]] +name = "zu-sqlite" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "rusqlite", + "zu-common", + "zu-storage", +] + +[[package]] +name = "zu-storage" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", + "zu-encoding", +] + +[[package]] +name = "zu-vector" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "zu-common", +] + +[[package]] +name = "zu-zu1" +version = "0.0.1" +source = "git+https://github.com/tamnd/zu?rev=67afd055032932eec36f4e373c2b82bdc4b188c9#67afd055032932eec36f4e373c2b82bdc4b188c9" +dependencies = [ + "crc32c", + "loom", + "zu-common", + "zu-encoding", +] + +[[package]] +name = "zudb-python" +version = "0.0.1" +dependencies = [ + "pyo3", + "zu", + "zu-common", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8395b87 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "zudb-python" +version = "0.0.1" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +repository = "https://github.com/tamnd/zu-python" +authors = ["Tam Nguyen "] +publish = false + +[lib] +name = "_zudb" +crate-type = ["cdylib"] + +[dependencies] +# The engine, by revision rather than by version. Nothing is published +# yet, and a binding is built against the engine commit it will ship +# with (ADR 0002), so a revision is the honest way to say which one. +# A local checkout is used instead with a `paths` override in +# `.cargo/config.toml`, which is untracked on purpose. +zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "67afd055032932eec36f4e373c2b82bdc4b188c9" } +zu-common = { git = "https://github.com/tamnd/zu", rev = "67afd055032932eec36f4e373c2b82bdc4b188c9" } +# `extension-module` is asked for by maturin, in pyproject.toml, and +# not here. Only the build backend knows how an extension is linked on +# the platform it is building for, and a crate that turns the feature +# on by default is a crate `cargo build` cannot link on its own. +pyo3 = { version = "0.29", features = ["abi3-py311"] } + +[profile.release] +lto = "thin" +strip = true diff --git a/README.md b/README.md index 215a984..14499e9 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,11 @@ The Python client for [zu](https://github.com/tamnd/zu), an embedded property-gr import zudb with zudb.connect("social.zu1") as conn: - conn.execute("CREATE NODE TABLE Person(id INT64 PRIMARY KEY, name STRING)") - conn.load_csv("Person", "people.csv") - - df = conn.sql(""" - MATCH (p:Person)-[:Follows]->(f) - RETURN p.name AS name, count(*) AS n ORDER BY n DESC LIMIT 5 - """).to_pandas() -print(df) + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + conn.execute("INSERT (p:person {uid: $uid, name: $name})", {"uid": 2, "name": "grace"}) + + for name, uid in conn.execute("MATCH (p:person) RETURN p.name AS name, p.uid AS uid"): + print(name, uid) ``` ``` @@ -24,7 +21,9 @@ No compiler, no `pkg-config`, no postinstall script. One wheel per platform with ## What this is -Built with PyO3 and maturin over `libzu`'s C ABI. The interesting parts: +Built with PyO3 and maturin, linked against the engine crates rather than against `libzu`'s C ABI. That is ADR 0002 in the engine repository, and it is about mechanism and not about contract: this client and every client that does go through `zu.h` answer the same conformance corpus, which is what says they agree. Linking the crates is what lets a query result reach Python without being flattened through C on the way. + +The interesting parts: - **Arrow all the way down.** `to_arrow()`, `to_pandas()`, `to_polars()`, and `record_batches()` go through the Arrow C Data Interface with no intermediate copy. `to_pandas()` hands back Arrow-backed dtypes, which is what pandas 3 wants anyway. - **`register()` replacement scans.** A DataFrame in your session becomes a table you can query by name, zero-copy for Arrow-backed frames. This is the DuckDB idea worth copying wholesale, and it deletes the write-to-disk-then-load step from every "load my data" tutorial. @@ -33,6 +32,10 @@ Built with PyO3 and maturin over `libzu`'s C ABI. The interesting parts: - **Complete `.pyi` stubs inside the wheel**, checked against the runtime in CI, so mypy and pyright and your editor all work with no extra install. - **Graph values are real classes.** `Node`, `Rel`, and `Path` have `.labels`, `.id`, `.properties`, and an HTML repr. Not dicts, because a dict cannot tell a property named `labels` apart from the label set. +## What works today + +The list above is what this client is for. What it does so far is the core of it: `connect`, `execute` and `sql` with named parameters, results that iterate and fetch, values as Python objects both ways including dates, times, datetimes and durations, `Node`, `Rel` and `Path` as classes, every condition as an exception class carrying its code, its position and its documentation link, and the GIL released around every statement. Arrow, `register`, the stubs and the interrupt are next, and each one lands with the tests that say it works. + ## Wheels Three per platform, which is more than it sounds like it should be and is not optional. The free-threaded CPython build has no stable ABI until 3.15 and [PEP 803](https://peps.python.org/pep-0803/)'s `abi3t`, so 3.14t needs a version-specific wheel of its own. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9902091 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["maturin>=1.14,<2.0"] +build-backend = "maturin" + +[project] +name = "zudb" +version = "0.0.1" +description = "zu: an embedded property-graph database, in your process" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +authors = [{ name = "Tam Nguyen", email = "tamnd87@gmail.com" }] +keywords = ["graph", "database", "gql", "embedded", "analytics"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Rust", + "Topic :: Database :: Database Engines/Servers", + "Typing :: Typed", +] +# Nothing. The wheel has the engine in it, and a notebook that wants a +# DataFrame back brings its own pandas. +dependencies = [] + +[project.urls] +Homepage = "https://zu.dev" +Source = "https://github.com/tamnd/zu-python" +Engine = "https://github.com/tamnd/zu" +Issues = "https://github.com/tamnd/zu-python/issues" + +[project.optional-dependencies] +arrow = ["pyarrow>=14"] +pandas = ["pandas>=2.0", "pyarrow>=14"] +polars = ["polars>=1.0"] + +[dependency-groups] +dev = ["maturin>=1.14,<2.0", "pytest>=8", "ruff>=0.9"] + +[tool.maturin] +python-source = "python" +module-name = "zudb._zudb" +# Only maturin knows how to link an extension module on every platform +# it builds for, so the feature is asked for here rather than being on +# by default in Cargo.toml, where it would break a plain `cargo build`. +features = ["pyo3/extension-module"] +strip = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/python/zudb/__init__.py b/python/zudb/__init__.py new file mode 100644 index 0000000..6a41e80 --- /dev/null +++ b/python/zudb/__init__.py @@ -0,0 +1,49 @@ +"""zu for Python: an embedded property-graph database, in your process. + + import zudb + + with zudb.connect("social.zu1") as conn: + rows = conn.execute("MATCH (p:person) RETURN p.name AS name") + for (name,) in rows: + print(name) + +The engine is compiled into the wheel, so there is nothing to install, +nothing to run, and no server to connect to. Statements are ISO/IEC +39075 GQL. +""" + +from __future__ import annotations + +from ._zudb import Connection, Duration, Node, Path, Rel, Result, __abi_version__, connect +from .errors import ( + ConnectionError, + DataError, + Error, + InternalError, + Interrupted, + ProgrammingError, + SyntaxError, + TransactionError, +) + +__version__ = "0.0.1" + +__all__ = [ + "connect", + "Connection", + "Result", + "Node", + "Rel", + "Path", + "Duration", + "Error", + "ConnectionError", + "DataError", + "TransactionError", + "SyntaxError", + "ProgrammingError", + "InternalError", + "Interrupted", + "__version__", + "__abi_version__", +] diff --git a/python/zudb/errors.py b/python/zudb/errors.py new file mode 100644 index 0000000..f2cbc5e --- /dev/null +++ b/python/zudb/errors.py @@ -0,0 +1,152 @@ +"""The exceptions zu raises, and the fields they carry. + +Every failure the engine reports is a GQLSTATUS condition: a +five-character code from ISO/IEC 39075, a severity, and often the place +in the statement that raised it. All of that arrives here as fields on +the exception, so a caller reads ``e.code`` and never a regular +expression over ``str(e)``. + +There is one class per condition class, which is what the two +characters that open a code are for. Catching :class:`DataError` +catches every one of the forty-two conditions in class 22 without +listing them, and a condition zu adds to that class later is caught by +the same ``except``. +""" + +from __future__ import annotations + +__all__ = [ + "Error", + "ConnectionError", + "DataError", + "TransactionError", + "SyntaxError", + "ProgrammingError", + "InternalError", + "Interrupted", +] + + +class Error(Exception): + """What every zu failure is, and the class to catch to catch them all. + + The fields are ``None`` when the condition has no answer for them + rather than being filled with a guess. A division by zero happens + at runtime and has no token to point at, so it carries a code and + no position; a statement that failed to parse carries both. + """ + + #: The five-character GQLSTATUS code, ``"42001"`` for a syntax + #: error. ``None`` for the few failures the standard has no + #: condition for, such as a statement that was interrupted. + code: str | None + #: The standard's own words for the condition, never paraphrased, + #: for example ``"syntax error or access rule violation, invalid + #: syntax"``. + condition: str | None + #: ``"exception"``, ``"warning"``, ``"informational"``, + #: ``"no_data"`` or ``"success"``. + severity: str | None + #: Line of the statement the condition was raised at, counting from + #: one. ``None``, with ``column`` and ``offset``, when the + #: condition happened somewhere the text cannot name. + line: int | None + #: Column on that line, counting from one, and a valid index into + #: ``excerpt`` after subtracting that one. + column: int | None + #: Bytes into the statement, counting from zero, for a caller that + #: wants to slice the text rather than print it. + offset: int | None + #: The whole line the position is on, quoted out of the statement, + #: for the caller who has the error and no longer has the text. + excerpt: str | None + #: The page that documents this condition. + doc_url: str | None + #: Whether running the same statement again could succeed. True + #: only for a transaction that rolled back with nothing done. + retryable: bool + + def __init__( + self, + message: str = "", + *, + code: str | None = None, + condition: str | None = None, + severity: str | None = None, + line: int | None = None, + column: int | None = None, + offset: int | None = None, + excerpt: str | None = None, + doc_url: str | None = None, + retryable: bool = False, + ) -> None: + super().__init__(message) + self.code = code + self.condition = condition + self.severity = severity + self.line = line + self.column = column + self.offset = offset + self.excerpt = excerpt + self.doc_url = doc_url + self.retryable = retryable + + def caret(self) -> str | None: + """The excerpt with a caret under the column, ready to print. + + ``None`` when there is no excerpt to point at. This is the one + piece of formatting the library does, because every caller that + prints an error writes it otherwise and half of them count the + column wrong. + """ + if self.excerpt is None or self.column is None: + return None + return f"{self.excerpt}\n{' ' * (self.column - 1)}^" + + +class ConnectionError(Error): + """Class 08: the database could not be reached or could not be read.""" + + +class DataError(Error): + """Class 22: a value was wrong. Division by zero, a bad cast, a number that did not fit.""" + + +class TransactionError(Error): + """Classes 25, 2D and 40: the transaction, rather than the statement, is what went wrong. + + Check :attr:`Error.retryable` before running it again. A rollback + with nothing done can be retried; a statement whose completion is + unknown cannot, because a retry could do the work twice. + """ + + +class SyntaxError(Error): # noqa: A001 - the standard's name for class 42 + """Class 42: the statement could not be parsed, or named something that is not there.""" + + +class ProgrammingError(Error): + """The caller made a mistake in Python. + + A statement on a closed connection, a parameter of a type zu has no + place for. Nothing reached the engine, so nothing happened to the + database. + """ + + +class InternalError(Error): + """A failure the engine could not describe as a condition. + + A corrupt file, an assumption that did not hold. Worth reporting at + https://github.com/tamnd/zu/issues. + """ + + +class Interrupted(Error): + """The statement was asked to stop and did. + + Nothing failed and nothing was lost: the connection keeps its plans + and its caches and runs the next statement normally. A ``Ctrl-C`` + raises ``KeyboardInterrupt`` instead, since that is what a person + at a keyboard means by it. + """ diff --git a/python/zudb/py.typed b/python/zudb/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..45d0ca1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,7 @@ +# The same compiler the engine pins, because this crate links the +# engine's crates directly and a binding built with a different one is +# a different build of the same code. Held to the version in +# tamnd/zu's toolchains.toml. +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] diff --git a/src/conn.rs b/src/conn.rs new file mode 100644 index 0000000..47c6b58 --- /dev/null +++ b/src/conn.rs @@ -0,0 +1,296 @@ +//! The connection and what a statement gives back. +//! +//! A connection is not thread-safe in the engine and every method +//! takes `&mut self` there, so the one held here sits behind a mutex. +//! That is not a way of making a connection concurrent: a statement +//! holds the mutex for as long as it runs, and two threads that want +//! to run statements at once want two connections. It is there so that +//! a program which shares one by accident waits rather than corrupts. + +use std::path::PathBuf; +use std::sync::Mutex; + +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList, PyTuple}; +use zudb::query::{QueryResult, Value}; +use zudb::{Config, Database}; + +use crate::error::{closed, to_py_err}; +use crate::value::{Names, from_py, to_py}; + +/// One connection to one database. +/// +/// Statements run on it in order, one at a time. It reads the database +/// as of when it was opened, which is why a program that wants to see +/// another writer's work takes a new connection rather than waiting on +/// this one. +#[pyclass(module = "zudb")] +pub struct Connection { + /// `None` once closed, which is what makes a second `close()` do + /// nothing and a statement after one an error rather than a crash. + inner: Mutex>, + #[pyo3(get)] + path: PathBuf, + #[pyo3(get)] + read_only: bool, +} + +#[pymethods] +impl Connection { + /// Runs one statement and gives back its rows. + /// + /// The parameters are named, never positional, because zuQL names + /// them: `$id` in the statement is `id` in the dictionary. A name + /// the statement does not use is an error from the engine rather + /// than a value quietly ignored. + #[pyo3(signature = (statement, params = None))] + fn execute( + &self, + py: Python<'_>, + statement: &str, + params: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + let params = bind(params)?; + let borrowed: Vec<(&str, Value)> = params + .iter() + .map(|(name, value)| (name.as_str(), value.clone())) + .collect(); + // The GIL goes down for the whole statement, waiting for the + // connection's own lock included. That is the point of a + // compiled engine in a Python process: another thread runs + // while this one is inside the executor, and the signal + // handler gets to run too, which is what lets a `Ctrl-C` + // arrive at all. Waiting for the lock with the GIL held would + // be worse than slow: the thread inside the statement has to + // take the GIL back to return, and it could not. + let (result, names) = py + .detach(|| -> std::result::Result<_, Trouble> { + let mut held = self.inner.lock().map_err(|_| Trouble::Closed)?; + let conn = held.as_mut().ok_or(Trouble::Closed)?; + let names = Names::of(conn.session_mut().catalog()); + let result = conn.query_with(statement, &borrowed)?; + Ok((result, names)) + }) + .map_err(|trouble| trouble.raise(py))?; + Ok(Result { + result, + names, + next: Mutex::new(0), + }) + } + + /// The same call, named for the way it reads in a notebook: + /// `conn.sql(...).to_pandas()`. + #[pyo3(signature = (statement, params = None))] + fn sql( + &self, + py: Python<'_>, + statement: &str, + params: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + self.execute(py, statement, params) + } + + /// Closes the connection and frees what it held. + /// + /// Doing it twice is not an error, because a `with` block that + /// closed early would otherwise fail on the way out. + fn close(&self, py: Python<'_>) { + // Released here too, because closing waits for the statement + // another thread is running and frees caches worth megabytes + // once it has. + py.detach(|| { + if let Ok(mut held) = self.inner.lock() { + drop(held.take()); + } + }); + } + + /// Whether this connection is still open. + #[getter] + fn closed(&self, py: Python<'_>) -> bool { + py.detach(|| self.inner.lock().map(|held| held.is_none()).unwrap_or(true)) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + #[pyo3(signature = (*_exception))] + fn __exit__(&self, py: Python<'_>, _exception: &Bound<'_, PyTuple>) -> bool { + self.close(py); + // False, so an exception raised inside the block carries on + // out of it. A context manager that swallowed one would be a + // very quiet way to lose an error. + false + } + + fn __repr__(&self, py: Python<'_>) -> String { + let state = if self.closed(py) { ", closed" } else { "" }; + format!("", self.path.display()) + } +} + +impl Connection { + /// Opens `path`, creating a database there when there is none. + /// + /// Creating is what every Python database module does and what a + /// notebook expects, and it can only ever create where nothing + /// was: a path that holds a database is opened, and a read-only + /// connection never creates anything at all. + pub fn open( + py: Python<'_>, + path: PathBuf, + read_only: bool, + memory_limit: Option, + threads: Option, + ) -> PyResult { + let mut config = Config::new().read_only(read_only); + if let Some(bytes) = memory_limit { + config = config.memory_limit(bytes); + } + if let Some(threads) = threads { + config = config.threads(threads); + } + let missing = !path.exists(); + let opened = py.detach(|| { + if missing && !read_only { + Database::create_with(&path, config.clone()) + } else { + Database::open_with(&path, config.clone()) + } + .and_then(|db| db.connect()) + }); + Ok(Connection { + inner: Mutex::new(Some(opened.map_err(|err| to_py_err(py, err))?)), + path, + read_only, + }) + } +} + +/// What can go wrong inside a statement, with the GIL down and no way +/// to build a Python exception yet. +enum Trouble { + Closed, + Engine(zudb::ZuError), +} + +impl From for Trouble { + fn from(err: zudb::ZuError) -> Trouble { + Trouble::Engine(err) + } +} + +impl Trouble { + fn raise(self, py: Python<'_>) -> PyErr { + match self { + // A connection whose lock a panic left poisoned is a + // connection nothing can be run on again, which is the + // same fact as a closed one and reads better as one. + Trouble::Closed => closed(py, "this connection"), + Trouble::Engine(err) => to_py_err(py, err), + } + } +} + +/// The rows a statement gave back. +/// +/// Held as the engine produced them and turned into Python objects +/// when they are read, so a result that is about to become an Arrow +/// table never pays for the objects it would have made. +#[pyclass(module = "zudb")] +pub struct Result { + result: QueryResult, + names: Names, + /// Where `fetchone` has reached, which is the only cursor here. + /// Iterating does not move it, because two callers reading one + /// result should not be able to take each other's rows. + next: Mutex, +} + +#[pymethods] +impl Result { + /// The column names, in the order the statement projected them. + #[getter] + fn columns(&self) -> Vec { + self.result.columns.clone() + } + + /// Every row, as a list of tuples. + fn fetchall<'py>(&self, py: Python<'py>) -> PyResult> { + let rows = PyList::empty(py); + for row in &self.result.rows { + rows.append(self.row(py, row)?)?; + } + Ok(rows) + } + + /// The next row, or `None` when there are no more. + fn fetchone<'py>(&self, py: Python<'py>) -> PyResult>> { + let mut next = self.next.lock().map_err(|_| { + pyo3::exceptions::PyRuntimeError::new_err("this result was left locked by a panic") + })?; + let Some(row) = self.result.rows.get(*next) else { + return Ok(None); + }; + *next += 1; + self.row(py, row).map(Some) + } + + /// The warnings the statement raised, if it raised any. A notice + /// is a condition that did not stop the statement, so it arrives + /// beside the rows rather than instead of them. + #[getter] + fn notices<'py>(&self, py: Python<'py>) -> PyResult> { + let out = PyList::empty(py); + for notice in &self.result.notices { + let one = PyDict::new(py); + one.set_item("code", notice.status.code())?; + one.set_item("condition", notice.status.standard_text())?; + one.set_item("detail", ¬ice.detail)?; + out.append(one)?; + } + Ok(out) + } + + fn __len__(&self) -> usize { + self.result.rows.len() + } + + fn __iter__<'py>(slf: PyRef<'py, Self>) -> PyResult> { + let py = slf.py(); + slf.fetchall(py)?.call_method0("__iter__") + } + + fn __repr__(&self) -> String { + format!( + "", + self.result.columns.len(), + self.result.rows.len() + ) + } +} + +impl Result { + fn row<'py>(&self, py: Python<'py>, row: &[Value]) -> PyResult> { + PyTuple::new( + py, + row.iter() + .map(|value| to_py(py, value, &self.names)) + .collect::>>()?, + ) + } +} + +/// The parameter dictionary as the engine takes it. +fn bind(params: Option<&Bound<'_, PyDict>>) -> PyResult> { + let Some(params) = params else { + return Ok(Vec::new()); + }; + let mut out = Vec::with_capacity(params.len()); + for (name, value) in params.iter() { + out.push((name.extract::()?, from_py(&value)?)); + } + Ok(out) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..38f4135 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,139 @@ +//! Raising an engine condition as a Python exception. +//! +//! The classes are defined in `zudb/errors.py` rather than here. A +//! Python programmer reads exception classes, subclasses them and +//! catches them by name, and a class written in Python has a +//! docstring, a readable signature and a stub the type checkers agree +//! about. What this module owns is the mapping: which class a +//! condition raises, and the fields it arrives with. +//! +//! Every field the error model gives a binding is a field here, and +//! none of them is parsed back out of a message. That is the whole +//! point of the model: `except zudb.SyntaxError as e: e.line` is what +//! a caller writes, never a regular expression over `str(e)`. + +use pyo3::prelude::*; +use pyo3::sync::PyOnceLock; +use pyo3::types::{PyDict, PyType}; +use zudb::ZuError; + +/// The `zudb.errors` module, imported once and kept. +/// +/// Imported lazily rather than at module init, because this extension +/// is imported *by* `zudb/__init__.py` and reaching back into a +/// package that is still executing its first line would be a circular +/// import. By the time anything fails, the package is there. +static ERRORS: PyOnceLock> = PyOnceLock::new(); + +fn errors(py: Python<'_>) -> PyResult<&Bound<'_, PyModule>> { + ERRORS + .get_or_try_init(py, || Ok(PyModule::import(py, "zudb.errors")?.unbind())) + .map(|module| module.bind(py)) +} + +/// Turns an engine error into the Python exception it is. +/// +/// A failure to build that exception is raised as itself, since a +/// broken import is a worse thing to hide than the condition that was +/// being reported. +pub fn to_py_err(py: Python<'_>, err: ZuError) -> PyErr { + // A statement that was stopped is reported as whatever stopped it. + // If a signal is what set the flag then Python has a + // `KeyboardInterrupt` waiting to be raised, and raising anything + // else would swallow the user's `Ctrl-C` and have them press it + // again, harder. + if matches!(err, ZuError::Interrupted) + && let Err(signal) = py.check_signals() + { + return signal; + } + match build(py, &err) { + Ok(raised) => raised, + Err(broken) => broken, + } +} + +fn build(py: Python<'_>, err: &ZuError) -> PyResult { + let class: Bound<'_, PyType> = errors(py)?.getattr(class_for(err))?.cast_into()?; + let fields = PyDict::new(py); + fields.set_item("retryable", err.retryable())?; + if let Some(status) = err.gqlstatus() { + fields.set_item("code", status.code())?; + fields.set_item("condition", status.standard_text())?; + fields.set_item("severity", severity(status.severity()))?; + fields.set_item("doc_url", status.doc_url())?; + } + if let Some(at) = err.position() { + fields.set_item("line", at.line)?; + fields.set_item("column", at.column)?; + fields.set_item("offset", at.offset)?; + } + if let Some(excerpt) = err.excerpt() { + fields.set_item("excerpt", excerpt)?; + } + Ok(PyErr::from_value( + class.call((err.to_string(),), Some(&fields))?, + )) +} + +/// The class a condition raises. +/// +/// One exception type per GQLSTATUS class, which is what the two +/// characters that open a code are for. A caller catching +/// `zudb.DataError` catches every one of the forty-two conditions in +/// class 22 without listing them, and a condition zu adds to that +/// class later is caught by the same `except`. +fn class_for(err: &ZuError) -> &'static str { + let Some(status) = err.gqlstatus() else { + return match err { + ZuError::Interrupted => "Interrupted", + ZuError::InvalidArgument(_) => "ProgrammingError", + ZuError::Conflict(_) => "TransactionError", + // A file that is not there, or not readable, is the same + // kind of thing as a database that cannot be reached, and + // class 08 is what the standard calls that. Reporting a + // missing path as an internal error would tell the caller + // to file a bug about their own typo. + ZuError::Io(_) => "ConnectionError", + _ => "InternalError", + }; + }; + match status.class() { + "08" => "ConnectionError", + "22" => "DataError", + "25" | "2D" | "40" => "TransactionError", + "42" => "SyntaxError", + _ => "Error", + } +} + +/// The severity letter's name, because `"exception"` is what a person +/// reading a traceback understands and `X` is what the standard's +/// table says. +fn severity(severity: zudb::Severity) -> &'static str { + match severity { + zudb::Severity::Success => "success", + zudb::Severity::NoData => "no_data", + zudb::Severity::Warning => "warning", + zudb::Severity::Informational => "informational", + zudb::Severity::Exception => "exception", + } +} + +/// What a call raises when it is made on a connection that is closed. +/// +/// A `ValueError` would be wrong and a segfault would be worse: the +/// caller made a mistake, in Python, and `zudb.ProgrammingError` is +/// the class for a mistake a program made rather than a condition the +/// engine raised. +pub fn closed(py: Python<'_>, what: &str) -> PyErr { + match errors(py).and_then(|errors| { + let class: Bound<'_, PyType> = errors.getattr("ProgrammingError")?.cast_into()?; + Ok(PyErr::from_value(class.call1((format!( + "{what} is closed, so there is nothing left to run a statement on" + ),))?)) + }) { + Ok(raised) => raised, + Err(broken) => broken, + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..54c33e7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,56 @@ +//! The zu extension module. +//! +//! What Python imports is the package `zudb`, and what the package +//! imports is this. Everything with an engine call behind it lives +//! here; everything that is only Python, the exception classes above +//! all, lives in the package, where it has a signature a reader can +//! see and a stub a checker can use. +//! +//! This links the engine crates directly rather than going through +//! `libzu`'s C ABI, which is ADR 0002: the hard part of a Python +//! binding is the runtime, not the FFI, and PyO3 is years of exactly +//! that work. What it owes in return is the ABI's semantics, and the +//! conformance corpus is what says whether it paid. + +mod conn; +mod error; +mod value; + +use std::path::PathBuf; + +use pyo3::prelude::*; + +/// Opens the database at `path` and connects to it. +/// +/// Creates one when the path holds nothing, which is what every Python +/// database module does and what a notebook expects. A read-only +/// connection never creates anything, so a mistyped path there is an +/// error rather than an empty database. +/// +/// `memory_limit` is in bytes and `threads` is how many the executor +/// may use; both default to what the engine decides for the machine. +#[pyfunction] +#[pyo3(signature = (path, *, read_only = false, memory_limit = None, threads = None))] +fn connect( + py: Python<'_>, + path: PathBuf, + read_only: bool, + memory_limit: Option, + threads: Option, +) -> PyResult { + conn::Connection::open(py, path, read_only, memory_limit, threads) +} + +#[pymodule] +fn _zudb(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add("__engine_version__", env!("CARGO_PKG_VERSION"))?; + module.add("__abi_version__", zudb::C_ABI_VERSION)?; + module.add_function(wrap_pyfunction!(connect, module)?)?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/src/value.rs b/src/value.rs new file mode 100644 index 0000000..22eb7bd --- /dev/null +++ b/src/value.rs @@ -0,0 +1,571 @@ +//! Values, both ways across the boundary. +//! +//! A row that arrives as Python objects is the slow path and the one +//! every notebook starts with, so it is the one that has to be +//! obvious: nulls are `None`, integers are `int`, and a graph value is +//! a class with named fields rather than a tuple whose third element +//! is the ordinal if you remember the order. The fast path is Arrow, +//! and nothing here is on it. + +use std::collections::HashMap; + +use pyo3::prelude::*; +use pyo3::types::{PyBool, PyDate, PyDateTime, PyDelta, PyDict, PyList, PyTime, PyTuple, PyTzInfo}; +use zu_common::temporal::{NANOS_PER_DAY, NANOS_PER_MINUTE, civil_from_days, days_from_civil}; +use zu_common::{DurationKind, Temporal}; +use zudb::query::Value; +use zudb::zu1::catalog::Catalog; + +/// Microseconds in a day, which is the unit `datetime.timedelta` +/// normalizes to and the one this has to split a duration across. +const MICROS_PER_DAY: i64 = 86_400 * 1_000_000; + +/// What the tables in a result are called. +/// +/// A node value carries the id of the table it came from and nothing +/// else, because that is what a row holds. A person reading a result +/// wants the name, so the names are taken off the catalog once when a +/// statement runs and carried with the rows. A catalog holds tens of +/// tables, so this is a copy of a few short strings and not a +/// structure worth sharing. +#[derive(Default)] +pub struct Names { + nodes: HashMap, + rels: HashMap, +} + +impl Names { + pub fn of(catalog: &Catalog) -> Names { + Names { + nodes: catalog + .node_tables() + .iter() + .map(|table| (table.id, table.name.clone())) + .collect(), + rels: catalog + .rel_tables() + .iter() + .map(|table| (table.id, table.name.clone())) + .collect(), + } + } + + /// The table's name, or its id written out for a table the catalog + /// no longer has. A result outlives nothing here, but a name is + /// for reading and an unreadable one should still print. + fn node(&self, id: u32) -> String { + self.nodes + .get(&id) + .cloned() + .unwrap_or_else(|| format!("#{id}")) + } + + fn rel(&self, id: u32) -> String { + self.rels + .get(&id) + .cloned() + .unwrap_or_else(|| format!("#{id}")) + } +} + +/// One node of the graph. +/// +/// The table is the name a person wrote in the schema, and the offset +/// is the row it sits at in that table, which together are what +/// identifies a node in zu. Properties and the full label set arrive +/// on a later release; what is here is what a row carries. +#[pyclass(module = "zudb", frozen, eq, hash, skip_from_py_object)] +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct Node { + #[pyo3(get)] + pub table: String, + #[pyo3(get)] + pub offset: u64, +} + +#[pymethods] +impl Node { + #[new] + fn new(table: String, offset: u64) -> Node { + Node { table, offset } + } + + fn __repr__(&self) -> String { + format!("Node({}, {})", self.table, self.offset) + } +} + +/// One edge of the graph. +/// +/// `ord` is which edge this is of the ones running from `src` to +/// `dst`, since a pair of endpoints can be joined more than once and +/// each of those edges carries its own properties. It is the field a +/// caller usually ignores and the one nothing else can replace. +#[pyclass(module = "zudb", frozen, eq, hash, skip_from_py_object)] +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct Rel { + #[pyo3(get)] + pub table: String, + #[pyo3(get)] + pub src: u64, + #[pyo3(get)] + pub dst: u64, + #[pyo3(get)] + pub ord: u64, +} + +#[pymethods] +impl Rel { + #[new] + fn new(table: String, src: u64, dst: u64, ord: u64) -> Rel { + Rel { + table, + src, + dst, + ord, + } + } + + fn __repr__(&self) -> String { + format!("Rel({}, {} -> {})", self.table, self.src, self.dst) + } +} + +/// A walk: nodes and edges alternating, a node at each end. +/// +/// `len` is the number of edges, which is the length of a path +/// everywhere else it is spoken about, so a path of one node has +/// length zero and is the shortest path there is. +#[pyclass(module = "zudb", frozen, skip_from_py_object)] +pub struct Path { + #[pyo3(get)] + pub elements: Py, +} + +#[pymethods] +impl Path { + #[new] + fn new(elements: Py) -> Path { + Path { elements } + } + + /// The nodes of the walk, in the order it visits them. + #[getter] + fn nodes<'py>(&self, py: Python<'py>) -> PyResult> { + self.every(py, 0) + } + + /// The edges of the walk, in the order it crosses them. + #[getter] + fn rels<'py>(&self, py: Python<'py>) -> PyResult> { + self.every(py, 1) + } + + fn every<'py>(&self, py: Python<'py>, from: usize) -> PyResult> { + let all = self.elements.bind(py); + let picked = (from..all.len()) + .step_by(2) + .map(|ix| all.get_item(ix)) + .collect::>>()?; + PyList::new(py, picked) + } + + fn __len__(&self, py: Python<'_>) -> usize { + self.elements.bind(py).len() / 2 + } + + fn __repr__(&self, py: Python<'_>) -> String { + format!("Path({} hops)", self.__len__(py)) + } +} + +/// A duration, which Python has no type for. +/// +/// `datetime.timedelta` is a day-time duration rounded to +/// microseconds, so it can hold neither of the two things zu stores: a +/// year-month duration is a count of months and no number of days is a +/// month, and a day-time duration is counted in nanoseconds. The +/// conversion is offered rather than done, so a caller who wants a +/// `timedelta` asks for one and knows what they gave up. +#[pyclass(module = "zudb", frozen, eq, hash, from_py_object)] +#[derive(PartialEq, Eq, Hash, Clone, Copy)] +pub struct Duration { + /// Months, for a year-month duration. Zero for a day-time one. + #[pyo3(get)] + pub months: i64, + /// Nanoseconds, for a day-time duration. Zero for a year-month + /// one. + #[pyo3(get)] + pub nanoseconds: i64, +} + +#[pymethods] +impl Duration { + #[new] + #[pyo3(signature = (months = 0, nanoseconds = 0))] + fn new(months: i64, nanoseconds: i64) -> PyResult { + if months != 0 && nanoseconds != 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "a duration counts months or nanoseconds and never both, since no number of days is a month", + )); + } + Ok(Duration { + months, + nanoseconds, + }) + } + + /// `"year_month"` or `"day_time"`, which is the distinction the + /// standard draws and the reason the two counts do not mix. + #[getter] + fn kind(&self) -> &'static str { + if self.months == 0 { + "day_time" + } else { + "year_month" + } + } + + /// The same duration as a `datetime.timedelta`, rounded towards + /// zero to the microsecond it can hold. A year-month duration has + /// no answer and says so. + #[allow(clippy::wrong_self_convention)] // `to_timedelta` is the name Python readers expect + fn to_timedelta<'py>(&self, py: Python<'py>) -> PyResult> { + if self.months != 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "a year-month duration is not a number of days, so there is no timedelta for it", + )); + } + // Split across the three counts the constructor takes rather + // than handed over as microseconds, because each of them is a + // C int and an hour is already more microseconds than one + // holds. The remainders keep their sign, which is what makes + // a negative duration round towards zero the way a positive + // one does. + let micros = self.nanoseconds / 1_000; + let days = micros / MICROS_PER_DAY; + let rest = micros % MICROS_PER_DAY; + let seconds = rest / 1_000_000; + let micros = rest % 1_000_000; + let count = |value: i64| { + i32::try_from(value).map_err(|_| { + pyo3::exceptions::PyOverflowError::new_err( + "this duration is longer than a timedelta can hold", + ) + }) + }; + PyDelta::new(py, count(days)?, count(seconds)?, count(micros)?, true) + } + + fn __repr__(&self) -> String { + if self.months == 0 { + format!("Duration(nanoseconds={})", self.nanoseconds) + } else { + format!("Duration(months={})", self.months) + } + } +} + +/// One engine value as the Python object it is. +pub fn to_py<'py>(py: Python<'py>, value: &Value, names: &Names) -> PyResult> { + Ok(match value { + Value::Null => py.None().into_bound(py), + Value::Bool(b) => PyBool::new(py, *b).to_owned().into_any(), + Value::Int(n) => n.into_pyobject(py)?.into_any(), + Value::Float(f) => f.into_pyobject(py)?.into_any(), + Value::Str(s) => s.into_pyobject(py)?.into_any(), + Value::Node { table, offset } => Node { + table: names.node(*table), + offset: *offset, + } + .into_pyobject(py)? + .into_any(), + Value::Rel { + table, + src, + dst, + ord, + } => Rel { + table: names.rel(*table), + src: *src, + dst: *dst, + ord: *ord, + } + .into_pyobject(py)? + .into_any(), + Value::List(items) => { + let out = PyList::empty(py); + for item in items { + out.append(to_py(py, item, names)?)?; + } + out.into_any() + } + Value::Record(fields) => { + let out = PyDict::new(py); + for (name, item) in fields { + out.set_item(name, to_py(py, item, names)?)?; + } + out.into_any() + } + Value::Path(walk) => { + let out = PyList::empty(py); + for step in walk { + out.append(to_py(py, step, names)?)?; + } + Path { + elements: out.unbind(), + } + .into_pyobject(py)? + .into_any() + } + Value::Temporal(t) => temporal_to_py(py, *t)?, + // The executor settles a chain into its edge list before any + // value leaves the pipeline, so a result never holds one and + // seeing one here is a bug in the engine rather than something + // a caller did. + Value::Chain(_) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err( + "an unsettled path chain reached a result, which is a bug: please report it at https://github.com/tamnd/zu/issues", + )); + } + }) +} + +fn temporal_to_py<'py>(py: Python<'py>, t: Temporal) -> PyResult> { + Ok(match t { + Temporal::Date(days) => { + let (y, m, d) = civil_from_days(days); + PyDate::new(py, y, m as u8, d as u8)?.into_any() + } + Temporal::LocalTime(nanos) => time_of(py, nanos, None)?.into_any(), + Temporal::ZonedTime { nanos, offset } => { + let zone = zone_of(py, offset)?; + time_of(py, nanos, Some(&zone))?.into_any() + } + Temporal::LocalDatetime(nanos) => datetime_of(py, nanos, None)?.into_any(), + Temporal::ZonedDatetime { nanos, offset } => { + // Stored as the instant in UTC with the offset it was + // written in, so the local reading is the instant moved + // back into that offset. Handing back the instant with a + // zone attached is what makes `.astimezone` and equality + // both answer what the writer meant. + let zone = zone_of(py, offset)?; + let local = nanos.saturating_add(i64::from(offset) * NANOS_PER_MINUTE); + datetime_of(py, local, Some(&zone))?.into_any() + } + Temporal::Duration(DurationKind::YearMonth, months) => Duration { + months, + nanoseconds: 0, + } + .into_pyobject(py)? + .into_any(), + Temporal::Duration(DurationKind::DayTime, nanos) => Duration { + months: 0, + nanoseconds: nanos, + } + .into_pyobject(py)? + .into_any(), + }) +} + +/// A nanosecond count since midnight as a `datetime.time`. +/// +/// Sub-microsecond digits are dropped, because `datetime` has no place +/// to put them. The Arrow path keeps every one of them, which is the +/// answer for a caller who cannot lose them. +fn time_of<'py>( + py: Python<'py>, + nanos: i64, + zone: Option<&Bound<'py, PyTzInfo>>, +) -> PyResult> { + let nanos = nanos.rem_euclid(NANOS_PER_DAY); + let micros = (nanos / 1_000) % 1_000_000; + let secs = nanos / 1_000_000_000; + PyTime::new( + py, + (secs / 3_600) as u8, + ((secs / 60) % 60) as u8, + (secs % 60) as u8, + micros as u32, + zone, + ) +} + +/// A nanosecond count since the epoch as a `datetime.datetime`. +fn datetime_of<'py>( + py: Python<'py>, + nanos: i64, + zone: Option<&Bound<'py, PyTzInfo>>, +) -> PyResult> { + let days = nanos.div_euclid(NANOS_PER_DAY); + let rest = nanos.rem_euclid(NANOS_PER_DAY); + let days = i32::try_from(days).map_err(|_| { + pyo3::exceptions::PyOverflowError::new_err("this datetime is outside the calendar") + })?; + let (y, m, d) = civil_from_days(days); + let micros = (rest / 1_000) % 1_000_000; + let secs = rest / 1_000_000_000; + PyDateTime::new( + py, + y, + m as u8, + d as u8, + (secs / 3_600) as u8, + ((secs / 60) % 60) as u8, + (secs % 60) as u8, + micros as u32, + zone, + ) +} + +/// An offset in minutes as a `datetime.timezone`. +fn zone_of(py: Python<'_>, offset: i16) -> PyResult> { + PyTzInfo::fixed_offset(py, PyDelta::new(py, 0, i32::from(offset) * 60, 0, true)?) +} + +/// One Python object as the engine value it is, for a parameter. +/// +/// The refusals are as much of the surface as the conversions. A +/// parameter zu cannot hold has to fail at the call rather than +/// arriving as a string, because a query that silently compared a +/// number against its own spelling would answer nothing and say +/// nothing. +pub fn from_py(value: &Bound<'_, PyAny>) -> PyResult { + if value.is_none() { + return Ok(Value::Null); + } + // Before the integer arm, because in Python every bool is an int + // and a parameter of `True` is not the parameter `1`. + if let Ok(b) = value.cast::() { + return Ok(Value::Bool(b.is_true())); + } + if let Ok(s) = value.extract::() { + return Ok(Value::Str(s)); + } + if let Ok(n) = value.extract::() { + return Ok(Value::Int(n)); + } + if let Ok(f) = value.extract::() { + return Ok(Value::Float(f)); + } + if let Ok(d) = value.extract::() { + return Ok(Value::Temporal(if d.months == 0 { + Temporal::Duration(DurationKind::DayTime, d.nanoseconds) + } else { + Temporal::Duration(DurationKind::YearMonth, d.months) + })); + } + // Datetime before date, since `datetime.datetime` is a subclass of + // `datetime.date` and reading one as the other would silently + // throw the time away. + if let Ok(dt) = value.cast::() { + return datetime_from_py(dt); + } + if let Ok(d) = value.cast::() { + return Ok(Value::Temporal(Temporal::Date(days_from_civil( + d.getattr("year")?.extract()?, + d.getattr("month")?.extract()?, + d.getattr("day")?.extract()?, + )))); + } + if let Ok(t) = value.cast::() { + return time_from_py(t); + } + if let Ok(delta) = value.cast::() { + let days: i64 = delta.getattr("days")?.extract()?; + let secs: i64 = delta.getattr("seconds")?.extract()?; + let micros: i64 = delta.getattr("microseconds")?.extract()?; + return Ok(Value::Temporal(Temporal::Duration( + DurationKind::DayTime, + days * NANOS_PER_DAY + secs * 1_000_000_000 + micros * 1_000, + ))); + } + if let Ok(items) = value.cast::() { + return Ok(Value::List( + items + .iter() + .map(|item| from_py(&item)) + .collect::>()?, + )); + } + if let Ok(items) = value.cast::() { + return Ok(Value::List( + items + .iter() + .map(|item| from_py(&item)) + .collect::>()?, + )); + } + if let Ok(fields) = value.cast::() { + let mut out = Vec::with_capacity(fields.len()); + for (name, item) in fields.iter() { + out.push((name.extract::()?, from_py(&item)?)); + } + return Ok(Value::record(out)); + } + Err(refused(value)) +} + +fn datetime_from_py(dt: &Bound<'_, PyDateTime>) -> PyResult { + let days = days_from_civil( + dt.getattr("year")?.extract()?, + dt.getattr("month")?.extract()?, + dt.getattr("day")?.extract()?, + ); + let nanos = i64::from(days) * NANOS_PER_DAY + clock_nanos(dt.as_any())?; + Ok(Value::Temporal(match offset_of(dt.as_any())? { + // Stored as the instant, which is the local reading with the + // offset taken back off it, and the offset kept beside it so + // the value still prints in the zone it was written in. + Some(offset) => Temporal::ZonedDatetime { + nanos: nanos - i64::from(offset) * NANOS_PER_MINUTE, + offset, + }, + None => Temporal::LocalDatetime(nanos), + })) +} + +fn time_from_py(t: &Bound<'_, PyTime>) -> PyResult { + let nanos = clock_nanos(t.as_any())?; + Ok(Value::Temporal(match offset_of(t.as_any())? { + Some(offset) => Temporal::ZonedTime { nanos, offset }, + None => Temporal::LocalTime(nanos), + })) +} + +/// The clock reading of a `time` or a `datetime`, in nanoseconds since +/// midnight. +fn clock_nanos(value: &Bound<'_, PyAny>) -> PyResult { + let hour: i64 = value.getattr("hour")?.extract()?; + let minute: i64 = value.getattr("minute")?.extract()?; + let second: i64 = value.getattr("second")?.extract()?; + let micro: i64 = value.getattr("microsecond")?.extract()?; + Ok(((hour * 60 + minute) * 60 + second) * 1_000_000_000 + micro * 1_000) +} + +/// The offset from UTC in minutes, for a value that carries one. +/// +/// A zone that is a rule rather than an offset, `ZoneInfo` above all, +/// is asked what its offset is for this value and stored as that. The +/// name is not stored, on purpose: a name is a rule that changes when +/// the zone database is updated, and a value that means something +/// different tomorrow is not a value. +fn offset_of(value: &Bound<'_, PyAny>) -> PyResult> { + let offset = value.call_method0("utcoffset")?; + if offset.is_none() { + return Ok(None); + } + let seconds: f64 = offset.call_method0("total_seconds")?.extract()?; + Ok(Some((seconds / 60.0) as i16)) +} + +fn refused(value: &Bound<'_, PyAny>) -> PyErr { + let name = value + .get_type() + .getattr("__name__") + .and_then(|name| name.extract::()) + .unwrap_or_else(|_| "that".to_string()); + pyo3::exceptions::PyTypeError::new_err(format!( + "a parameter cannot be a {name}: zu holds nulls, booleans, integers, floats, strings, lists, records, dates, times, datetimes and durations" + )) +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3c8177b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +"""Fixtures shared by the suite. + +Every test gets a database of its own under a temporary directory, so a +test that writes cannot change what another test reads and a failure +leaves the file behind for exactly as long as pytest keeps the +directory. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import zudb + +PEOPLE = [(10, "ada", 36.5), (20, "grace", 45.0), (30, "kay", 22.25)] + + +@pytest.fixture +def empty(tmp_path: Path) -> zudb.Connection: + """A database with nothing in it.""" + conn = zudb.connect(tmp_path / "empty.zu1") + yield conn + conn.close() + + +@pytest.fixture +def social(tmp_path: Path) -> zudb.Connection: + """Three people, written the way the engine writes them. + + The property is `uid` rather than `id` because `id` is the name the + v0 engine gives a node's offset, and a table that carries one of + its own is a table where the two disagree. + + The first row is written out rather than parameterized, because the + table does not exist yet and a value that has to be worked out + first says nothing about the column it would go in. Every row after + it goes in as parameters, which is the way a program writes rows. + """ + conn = zudb.connect(tmp_path / "social.zu1") + first, rest = PEOPLE[0], PEOPLE[1:] + conn.execute(f"INSERT (p:person {{uid: {first[0]}, name: '{first[1]}', score: {first[2]}}})") + for uid, name, score in rest: + conn.execute( + "INSERT (p:person {uid: $uid, name: $name, score: $score})", + {"uid": uid, "name": name, "score": score}, + ) + yield conn + conn.close() + + +@pytest.fixture +def crowd(tmp_path: Path) -> zudb.Connection: + """Enough people that a statement over the pairs of them takes long + enough to time, which is what the threading tests need. + """ + conn = zudb.connect(tmp_path / "crowd.zu1") + conn.execute("INSERT (p:person {uid: 0, name: 'seed'})") + conn.execute( + "UNWIND $rows AS r INSERT (p:person {uid: r.uid, name: r.name})", + {"rows": [{"uid": uid, "name": f"p{uid}"} for uid in range(1, 150)]}, + ) + yield conn + conn.close() diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..35c37e1 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,87 @@ +"""Opening, closing, and what a connection says about itself.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import zudb + + +def test_connect_creates_a_database_that_was_not_there(tmp_path: Path) -> None: + path = tmp_path / "new.zu1" + assert not path.exists() + conn = zudb.connect(path) + assert path.exists() + assert conn.path == path + assert conn.read_only is False + conn.close() + + +def test_a_written_row_is_there_on_the_next_connection(tmp_path: Path) -> None: + path = tmp_path / "again.zu1" + with zudb.connect(path) as conn: + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + with zudb.connect(path) as conn: + assert conn.execute("MATCH (p:person) RETURN p.name AS n").fetchall() == [("ada",)] + + +def test_a_read_only_connection_never_creates(tmp_path: Path) -> None: + path = tmp_path / "absent.zu1" + with pytest.raises(zudb.ConnectionError): + zudb.connect(path, read_only=True) + assert not path.exists() + + +def test_a_read_only_connection_reads(tmp_path: Path) -> None: + path = tmp_path / "frozen.zu1" + with zudb.connect(path) as conn: + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + with zudb.connect(path, read_only=True) as conn: + assert conn.read_only is True + assert conn.execute("MATCH (p:person) RETURN p.name AS n").fetchall() == [("ada",)] + + +def test_closing_twice_is_not_an_error(empty: zudb.Connection) -> None: + assert empty.closed is False + empty.close() + assert empty.closed is True + empty.close() + assert empty.closed is True + + +def test_a_statement_after_close_says_so(empty: zudb.Connection) -> None: + empty.close() + with pytest.raises(zudb.ProgrammingError, match="closed"): + empty.execute("RETURN 1 AS one") + + +def test_the_block_closes_on_the_way_out(tmp_path: Path) -> None: + with zudb.connect(tmp_path / "block.zu1") as conn: + assert conn.closed is False + assert conn.closed is True + + +def test_an_exception_leaves_the_block_rather_than_being_swallowed(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="mine"): + with zudb.connect(tmp_path / "raise.zu1") as conn: + raise ValueError("mine") + assert conn.closed is True + + +def test_repr_names_the_file_and_says_when_it_is_closed(tmp_path: Path) -> None: + conn = zudb.connect(tmp_path / "shown.zu1") + assert "shown.zu1" in repr(conn) + assert "closed" not in repr(conn) + conn.close() + assert "closed" in repr(conn) + + +def test_the_engine_and_abi_versions_are_reported(empty: zudb.Connection) -> None: + assert zudb.__version__ == "0.0.1" + assert zudb.__abi_version__.count(".") == 1 + + +def test_a_memory_limit_and_a_thread_count_are_accepted(tmp_path: Path) -> None: + with zudb.connect(tmp_path / "tuned.zu1", memory_limit=64 << 20, threads=2) as conn: + assert conn.execute("RETURN 1 AS one").fetchall() == [(1,)] diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..2b7bc29 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,92 @@ +"""Conditions as exceptions, with the fields the error model promises.""" + +from __future__ import annotations + +import pytest +import zudb + + +def test_a_syntax_error_carries_its_code_and_its_place(empty: zudb.Connection) -> None: + with pytest.raises(zudb.SyntaxError) as raised: + empty.execute("MATCH (p:person) RETRUN p") + err = raised.value + assert err.code.startswith("42") + assert err.condition + assert err.severity == "exception" + assert err.line == 1 + assert err.column > 1 + assert err.offset > 0 + assert err.retryable is False + + +def test_the_doc_url_points_at_the_page_for_the_code(empty: zudb.Connection) -> None: + with pytest.raises(zudb.SyntaxError) as raised: + empty.execute("MATCH (p:person) RETRUN p") + err = raised.value + assert err.doc_url == f"https://zu.dev/docs/errors/{err.code.lower()}" + + +def test_an_excerpt_and_a_caret_point_at_the_token(empty: zudb.Connection) -> None: + statement = "MATCH (p:person) RETRUN p" + with pytest.raises(zudb.SyntaxError) as raised: + empty.execute(statement) + err = raised.value + assert err.excerpt == statement + assert statement[err.offset :].startswith("RETRUN") + quoted, pointer = err.caret().splitlines() + assert quoted == statement + assert pointer.index("^") == err.column - 1 + + +def test_a_label_nothing_declares_is_a_reference_error(empty: zudb.Connection) -> None: + with pytest.raises(zudb.Error) as raised: + empty.execute("MATCH (p:person)-[:follows]->(q:person) RETURN p") + assert raised.value.code == "42002" + + +def test_every_condition_is_catchable_as_the_base_class(empty: zudb.Connection) -> None: + with pytest.raises(zudb.Error): + empty.execute("this is not a statement") + + +def test_a_condition_is_an_exception_and_reads_like_one(empty: zudb.Connection) -> None: + with pytest.raises(zudb.Error) as raised: + empty.execute("MATCH (p:person) RETURN") + assert isinstance(raised.value, Exception) + assert str(raised.value) + + +def test_a_wrong_type_in_a_value_is_a_data_error(social: zudb.Connection) -> None: + with pytest.raises(zudb.DataError) as raised: + social.execute("INSERT (p:person {uid: 'ten', name: 'zoe', score: 1.0})") + assert raised.value.code.startswith("22") + + +def test_a_mistake_the_program_made_is_a_programming_error(empty: zudb.Connection) -> None: + empty.close() + with pytest.raises(zudb.ProgrammingError): + empty.execute("RETURN 1 AS one") + + +def test_a_missing_database_is_a_connection_error(tmp_path) -> None: + with pytest.raises(zudb.ConnectionError): + zudb.connect(tmp_path / "not-here.zu1", read_only=True) + + +def test_the_error_classes_are_zu_classes_and_not_the_builtins() -> None: + assert zudb.SyntaxError is not SyntaxError + assert issubclass(zudb.SyntaxError, zudb.Error) + assert issubclass(zudb.DataError, zudb.Error) + assert issubclass(zudb.ConnectionError, zudb.Error) + assert issubclass(zudb.TransactionError, zudb.Error) + assert issubclass(zudb.ProgrammingError, zudb.Error) + assert issubclass(zudb.InternalError, zudb.Error) + assert issubclass(zudb.Interrupted, zudb.Error) + + +def test_a_condition_that_never_happened_leaves_the_fields_empty() -> None: + err = zudb.Error("nothing in particular") + assert err.code is None + assert err.line is None + assert err.caret() is None + assert str(err) == "nothing in particular" diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 0000000..47fc0e3 --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,83 @@ +"""Parameters, which are named and typed and never string formatting.""" + +from __future__ import annotations + +import datetime + +import pytest +import zudb + + +@pytest.mark.parametrize( + "value", + [ + None, + True, + False, + 0, + -7, + 1 << 40, + 1.5, + "ada", + "", + [1, 2, 3], + [1, "a", None], + [], + datetime.date(2020, 1, 2), + datetime.time(1, 2, 3), + datetime.time(1, 2, 3, 400_000), + datetime.datetime(2020, 1, 2, 3, 4, 5), + datetime.datetime(2020, 1, 2, 3, 4, 5, 6), + datetime.datetime(2020, 1, 2, 3, 4, 5, tzinfo=datetime.UTC), + zudb.Duration(months=14), + zudb.Duration(nanoseconds=90_061_000_000_000), + ], +) +def test_a_parameter_comes_back_as_what_went_in(empty: zudb.Connection, value: object) -> None: + assert empty.execute("RETURN $p AS p", {"p": value}).fetchone() == (value,) + + +def test_a_tuple_is_a_list_on_the_way_in(empty: zudb.Connection) -> None: + assert empty.execute("RETURN $p AS p", {"p": (1, 2)}).fetchone() == ([1, 2],) + + +def test_a_timedelta_becomes_a_day_time_duration(empty: zudb.Connection) -> None: + delta = datetime.timedelta(days=2, seconds=3, microseconds=4) + got = empty.execute("RETURN $p AS p", {"p": delta}).fetchone()[0] + assert got == zudb.Duration(nanoseconds=172_803_000_004_000) + assert got.to_timedelta() == delta + + +def test_several_parameters_are_told_apart_by_name(empty: zudb.Connection) -> None: + rows = empty.execute("RETURN $a AS a, $b AS b", {"a": 1, "b": "two"}) + assert rows.fetchall() == [(1, "two")] + + +def test_a_parameter_is_a_value_and_never_a_fragment_of_the_statement( + social: zudb.Connection, +) -> None: + hostile = "ada' RETURN 1 AS pwned MATCH (p:person) WHERE p.name = 'x" + rows = social.execute("MATCH (p:person) WHERE p.name = $n RETURN p.name AS n", {"n": hostile}) + assert rows.fetchall() == [] + + +def test_a_parameter_filters_the_same_way_a_literal_does(social: zudb.Connection) -> None: + by_name = social.execute( + "MATCH (p:person) WHERE p.name = $n RETURN p.uid AS uid", {"n": "grace"} + ) + assert by_name.fetchall() == [(20,)] + + +def test_no_parameters_at_all_is_fine(social: zudb.Connection) -> None: + assert social.execute("MATCH (p:person) RETURN count(p) AS n").fetchall() == [(3,)] + + +def test_a_type_zu_does_not_hold_is_refused_by_name(empty: zudb.Connection) -> None: + with pytest.raises(TypeError, match="cannot be a object"): + empty.execute("RETURN $p AS p", {"p": object()}) + + +def test_a_bool_is_a_bool_and_not_the_integer_one(empty: zudb.Connection) -> None: + got = empty.execute("RETURN $p AS p", {"p": True}).fetchone()[0] + assert got is True + assert type(got) is bool diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..26e2e74 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,83 @@ +"""Running a statement and reading the rows back.""" + +from __future__ import annotations + +import pytest +import zudb +from conftest import PEOPLE + + +def test_columns_come_back_in_the_order_they_were_projected(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.name AS name, p.uid AS uid") + assert rows.columns == ["name", "uid"] + + +def test_fetchall_gives_every_row_as_a_tuple(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid, p.name AS name ORDER BY uid") + assert rows.fetchall() == [(uid, name) for uid, name, _ in PEOPLE] + + +def test_fetchone_walks_the_rows_and_then_answers_none(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid ORDER BY uid") + assert rows.fetchone() == (10,) + assert rows.fetchone() == (20,) + assert rows.fetchone() == (30,) + assert rows.fetchone() is None + assert rows.fetchone() is None + + +def test_iterating_does_not_move_the_cursor(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid ORDER BY uid") + assert [uid for (uid,) in rows] == [10, 20, 30] + assert rows.fetchone() == (10,) + assert [uid for (uid,) in rows] == [10, 20, 30] + + +def test_a_result_knows_how_many_rows_it_holds(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid") + assert len(rows) == 3 + assert len(social.execute("MATCH (p:person) WHERE p.uid > 100 RETURN p.uid AS uid")) == 0 + + +def test_repr_says_the_shape(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.uid AS uid, p.name AS name") + assert repr(rows) == "" + + +def test_sql_is_the_same_call_under_the_name_a_notebook_uses(social: zudb.Connection) -> None: + assert social.sql("MATCH (p:person) RETURN count(p) AS n").fetchall() == [(3,)] + + +def test_aggregation_and_ordering_run(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p.name AS name ORDER BY p.score DESC LIMIT 2") + assert rows.fetchall() == [("grace",), ("ada",)] + + +def test_a_statement_that_writes_answers_no_columns(empty: zudb.Connection) -> None: + rows = empty.execute("INSERT (p:person {uid: 1, name: 'ada'})") + assert rows.columns == [] + assert len(rows) == 0 + + +def test_a_statement_with_nothing_to_say_has_no_notices(social: zudb.Connection) -> None: + assert social.execute("MATCH (p:person) RETURN p.uid AS uid").notices == [] + + +def test_two_statements_on_one_connection_run_in_order(empty: zudb.Connection) -> None: + empty.execute("INSERT (p:person {uid: 1, name: 'ada'})") + empty.execute("INSERT (p:person {uid: 2, name: 'kay'})") + assert empty.execute("MATCH (p:person) RETURN count(p) AS n").fetchall() == [(2,)] + + +@pytest.mark.parametrize( + "statement,answer", + [ + ("RETURN 1 + 1 AS n", 2), + ("RETURN 'a' + 'b' AS s", "ab"), + ("UNWIND [1, 2, 3] AS n RETURN sum(n) AS total", 6), + ], +) +def test_expressions_answer_without_touching_the_graph( + empty: zudb.Connection, statement: str, answer: object +) -> None: + assert empty.execute(statement).fetchall() == [(answer,)] diff --git a/tests/test_threads.py b/tests/test_threads.py new file mode 100644 index 0000000..78964f7 --- /dev/null +++ b/tests/test_threads.py @@ -0,0 +1,83 @@ +"""What happens when more than one thread is involved. + +A connection is not concurrent and never claims to be: statements on +one run in the order they were asked for, one at a time. What these +check is that the lock which enforces that is taken with the GIL +released, since a thread waiting for the connection while holding the +GIL would stop the thread inside the engine from ever returning. +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +import zudb + +# Every pair of people, filtered, which is a statement that runs for +# long enough to watch rather than one that is over before the other +# thread is scheduled. +WORK = "MATCH (a:person), (b:person) WHERE a.uid < b.uid RETURN count(a) AS n" +PAIRS = 150 * 149 // 2 + + +def test_two_threads_sharing_one_connection_both_finish(crowd: zudb.Connection) -> None: + answers: list[object] = [] + lock = threading.Lock() + + def run() -> None: + got = crowd.execute(WORK).fetchone() + with lock: + answers.append(got) + + threads = [threading.Thread(target=run) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=120) + assert not thread.is_alive(), "a thread is still waiting for the connection" + assert answers == [(PAIRS,)] * 4 + + +def test_a_connection_per_thread_reads_the_same_database(tmp_path: Path) -> None: + path = tmp_path / "shared.zu1" + with zudb.connect(path) as conn: + conn.execute("INSERT (p:person {uid: 1, name: 'ada'})") + + answers: list[object] = [] + lock = threading.Lock() + + def run() -> None: + with zudb.connect(path, read_only=True) as conn: + got = conn.execute("MATCH (p:person) RETURN p.name AS n").fetchone() + with lock: + answers.append(got) + + threads = [threading.Thread(target=run) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=120) + assert not thread.is_alive(), "a thread is still waiting for the database" + assert answers == [("ada",)] * 4 + + +def test_python_keeps_running_while_a_statement_does(crowd: zudb.Connection) -> None: + ticks = 0 + done = threading.Event() + + def run() -> None: + for _ in range(4): + crowd.execute(WORK) + done.set() + + worker = threading.Thread(target=run) + worker.start() + while not done.is_set(): + ticks += 1 + worker.join(timeout=120) + assert not worker.is_alive() + # A GIL held for the length of a statement would leave this loop + # the gaps between four of them, which is a handful of turns and + # not thousands of them. + assert ticks > 1000, f"the main thread only got {ticks} turns" diff --git a/tests/test_values.py b/tests/test_values.py new file mode 100644 index 0000000..a231e0a --- /dev/null +++ b/tests/test_values.py @@ -0,0 +1,76 @@ +"""What an engine value is once it is a Python object.""" + +from __future__ import annotations + +import datetime + +import pytest +import zudb + + +def test_a_node_carries_its_table_and_offset(social: zudb.Connection) -> None: + rows = social.execute("MATCH (p:person) RETURN p ORDER BY p.uid") + nodes = [node for (node,) in rows] + assert [node.table for node in nodes] == ["person"] * 3 + assert [node.offset for node in nodes] == [0, 1, 2] + assert repr(nodes[0]) == "Node(person, 0)" + + +def test_two_reads_of_one_node_are_equal_and_hash_alike(social: zudb.Connection) -> None: + one = social.execute("MATCH (p:person) WHERE p.uid = 10 RETURN p").fetchone()[0] + two = social.execute("MATCH (p:person) WHERE p.uid = 10 RETURN p").fetchone()[0] + assert one == two + assert len({one, two}) == 1 + + +@pytest.mark.parametrize( + "statement,answer", + [ + ("RETURN 1 AS v", 1), + ("RETURN 1.5 AS v", 1.5), + ("RETURN 'ada' AS v", "ada"), + ("RETURN true AS v", True), + ("RETURN false AS v", False), + ("RETURN null AS v", None), + ("RETURN [1, 2, 3] AS v", [1, 2, 3]), + ("RETURN [[1], [2, 3]] AS v", [[1], [2, 3]]), + ("RETURN [] AS v", []), + ], +) +def test_a_literal_reads_back_as_the_python_object_it_is( + empty: zudb.Connection, statement: str, answer: object +) -> None: + got = empty.execute(statement).fetchone()[0] + assert got == answer + assert type(got) is type(answer) + + +def test_a_duration_counts_months_or_nanoseconds_and_never_both() -> None: + with pytest.raises(ValueError, match="never both"): + zudb.Duration(months=1, nanoseconds=1) + + +def test_a_duration_says_which_kind_it_is() -> None: + assert zudb.Duration(months=14).kind == "year_month" + assert zudb.Duration(nanoseconds=1).kind == "day_time" + assert zudb.Duration().kind == "day_time" + + +def test_a_day_time_duration_converts_to_a_timedelta() -> None: + hour = zudb.Duration(nanoseconds=3_600_000_000_000) + assert hour.to_timedelta() == datetime.timedelta(hours=1) + + +def test_a_conversion_that_rounds_rounds_towards_zero() -> None: + assert zudb.Duration(nanoseconds=1_500).to_timedelta() == datetime.timedelta(microseconds=1) + assert zudb.Duration(nanoseconds=-1_500).to_timedelta() == datetime.timedelta(microseconds=-1) + + +def test_a_year_month_duration_has_no_timedelta() -> None: + with pytest.raises(ValueError, match="no timedelta"): + zudb.Duration(months=3).to_timedelta() + + +def test_a_duration_repr_names_the_count_it_carries() -> None: + assert repr(zudb.Duration(months=3)) == "Duration(months=3)" + assert repr(zudb.Duration(nanoseconds=3)) == "Duration(nanoseconds=3)"